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,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:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user