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,162 @@
|
||||
// Package store persists playlink state in SQLite.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
var migrations = []string{
|
||||
`CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE pairs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
host_root TEXT NOT NULL,
|
||||
slave_root TEXT NOT NULL,
|
||||
quarantine_dir TEXT NOT NULL,
|
||||
extensions TEXT NOT NULL,
|
||||
auto_link_identical INTEGER NOT NULL DEFAULT 0,
|
||||
scan_interval_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
settle_minutes INTEGER NOT NULL DEFAULT 10,
|
||||
last_scan_at INTEGER,
|
||||
last_scan_error TEXT NOT NULL DEFAULT '',
|
||||
last_scan_warnings TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE matches (
|
||||
id INTEGER PRIMARY KEY,
|
||||
pair_id INTEGER NOT NULL REFERENCES pairs(id) ON DELETE CASCADE,
|
||||
rel_slave TEXT NOT NULL,
|
||||
rel_host TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
host_ino INTEGER NOT NULL DEFAULT 0,
|
||||
host_size INTEGER NOT NULL DEFAULT 0,
|
||||
host_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
slave_ino INTEGER NOT NULL DEFAULT 0,
|
||||
slave_size INTEGER NOT NULL DEFAULT 0,
|
||||
slave_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
host_probe TEXT NOT NULL DEFAULT '',
|
||||
slave_probe TEXT NOT NULL DEFAULT '',
|
||||
verdict TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
ignored INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(pair_id, rel_slave)
|
||||
);
|
||||
CREATE INDEX matches_pair_status ON matches(pair_id, status);
|
||||
CREATE TABLE operations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
pair_id INTEGER NOT NULL REFERENCES pairs(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
rel_host TEXT NOT NULL DEFAULT '',
|
||||
rel_slave TEXT NOT NULL DEFAULT '',
|
||||
rel_new TEXT NOT NULL DEFAULT '',
|
||||
quarantine_path TEXT NOT NULL DEFAULT '',
|
||||
quarantine_side TEXT NOT NULL DEFAULT '',
|
||||
tmp_path TEXT NOT NULL DEFAULT '',
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||
trigger TEXT NOT NULL DEFAULT 'manual',
|
||||
status TEXT NOT NULL,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
undone_at INTEGER,
|
||||
purged_at INTEGER
|
||||
);
|
||||
CREATE INDEX operations_pair ON operations(pair_id, created_at DESC);`,
|
||||
}
|
||||
|
||||
// Open opens (and migrates) the database in dataDir.
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
dsn := "file:" + filepath.Join(dataDir, "playlink.db") +
|
||||
"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1) // SQLite: one writer; keeps things simple and safe.
|
||||
s := &Store{DB: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
if _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS schema_version (v INTEGER NOT NULL)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var v int
|
||||
if err := s.DB.QueryRow(`SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := v; i < len(migrations); i++ {
|
||||
tx, err := s.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_version (v) VALUES (?)`, i+1); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func now() int64 { return time.Now().Unix() }
|
||||
|
||||
// ---- settings ----
|
||||
|
||||
func (s *Store) Setting(ctx context.Context, key, def string) string {
|
||||
var v string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v); err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// DryRun is the global safety switch. It defaults to on for new installs.
|
||||
func (s *Store) DryRun(ctx context.Context) bool {
|
||||
return s.Setting(ctx, "dry_run", "1") == "1"
|
||||
}
|
||||
Reference in new issue
Block a user