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

+153
View File
@@ -0,0 +1,153 @@
// Command playlink deduplicates music libraries with hardlinks.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/cdnutter/playlink/internal/api"
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
"github.com/cdnutter/playlink/web"
)
var version = "dev"
func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func main() {
level := slog.LevelInfo
if env("PLAYLINK_LOG_LEVEL", "info") == "debug" {
level = slog.LevelDebug
}
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
addr := env("PLAYLINK_ADDR", ":7480")
dataDir := env("PLAYLINK_DATA_DIR", "./config")
secure := env("PLAYLINK_SECURE_COOKIES", "false") == "true"
// Every library, quarantine and folder-picker path must live under one of
// these roots. In Docker this is the mounted music volume.
var roots []string
for _, r := range strings.Split(env("PLAYLINK_ALLOWED_ROOTS", "/data"), ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !filepath.IsAbs(r) {
log.Error("allowed root must be absolute", "root", r)
os.Exit(1)
}
real, err := filepath.EvalSymlinks(filepath.Clean(r))
if err != nil {
log.Warn("allowed root is not accessible", "root", r, "err", err)
continue
}
if real == "/" {
log.Error("refusing to allow the filesystem root; set PLAYLINK_ALLOWED_ROOTS to your music folder")
os.Exit(1)
}
roots = append(roots, real)
}
if len(roots) == 0 {
log.Error("no usable PLAYLINK_ALLOWED_ROOTS")
os.Exit(1)
}
ffprobe := env("PLAYLINK_FFPROBE", "ffprobe")
if p, err := exec.LookPath(ffprobe); err == nil {
ffprobe = p
} else {
log.Warn("ffprobe not found; quality comparison disabled", "bin", ffprobe)
ffprobe = ""
}
if err := os.MkdirAll(dataDir, 0o750); err != nil {
log.Error("create data dir", "err", err)
os.Exit(1)
}
st, err := store.Open(dataDir)
if err != nil {
log.Error("open database", "err", err)
os.Exit(1)
}
defer st.DB.Close()
eng := engine.New(st, probe.Prober{Bin: ffprobe}, roots, log)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
eng.Recover(ctx)
go scheduler(ctx, st, eng, log)
srv := &http.Server{
Addr: addr,
Handler: api.New(api.Config{SecureCookies: secure, Version: version}, st, eng, log, web.Dist()).Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
}
go func() {
<-ctx.Done()
shut, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shut)
}()
log.Info("playlink listening", "addr", addr, "version", version, "allowed_roots", roots, "data", dataDir,
"dry_run", st.DryRun(ctx))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("server", "err", err)
os.Exit(1)
}
}
// scheduler starts interval scans and hourly maintenance.
func scheduler(ctx context.Context, st *store.Store, eng *engine.Engine, log *slog.Logger) {
tick := time.NewTicker(time.Minute)
defer tick.Stop()
lastMaint := time.Time{}
for {
if time.Since(lastMaint) >= time.Hour {
eng.Maintenance(ctx)
lastMaint = time.Now()
}
if pairs, err := st.Pairs(ctx); err == nil {
for _, p := range pairs {
if p.ScanIntervalMinutes <= 0 {
continue
}
due := p.LastScanAt == nil || time.Since(time.Unix(*p.LastScanAt, 0)) >= time.Duration(p.ScanIntervalMinutes)*time.Minute
if !due {
continue
}
if j := eng.Job(p.ID); j != nil && j.State == "running" {
continue
}
if _, err := eng.StartScan(p.ID, "schedule"); err == nil {
log.Info("scheduled scan started", "pair", p.Name)
}
}
}
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}