// Package store persists playlink state in SQLite. package store import ( "context" "database/sql" "fmt" "path/filepath" "strings" "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);`, // 2: the "host" library was renamed to "master". Rename in place so // existing pairs, history and quarantine entries carry over. `ALTER TABLE pairs RENAME COLUMN host_root TO master_root; ALTER TABLE matches RENAME COLUMN rel_host TO rel_master; ALTER TABLE matches RENAME COLUMN host_ino TO master_ino; ALTER TABLE matches RENAME COLUMN host_size TO master_size; ALTER TABLE matches RENAME COLUMN host_mtime TO master_mtime; ALTER TABLE matches RENAME COLUMN host_probe TO master_probe; ALTER TABLE operations RENAME COLUMN rel_host TO rel_master; UPDATE operations SET quarantine_side = 'master' WHERE quarantine_side = 'host'; UPDATE matches SET verdict = replace(verdict, 'host', 'master'); UPDATE pairs SET last_scan_warnings = replace(last_scan_warnings, 'host', 'master');`, // 3: opt-in approximate matching, and the reason shown for such matches. `ALTER TABLE pairs ADD COLUMN approx_matching INTEGER NOT NULL DEFAULT 0; ALTER TABLE pairs ADD COLUMN approx_threshold INTEGER NOT NULL DEFAULT 85; ALTER TABLE matches ADD COLUMN reason TEXT NOT NULL DEFAULT '';`, // 4: remember how an operation's match was found, so undo can restore // matches that are not at the same path (other format, approximate, ...). `ALTER TABLE operations ADD COLUMN match_kind TEXT NOT NULL DEFAULT ''; ALTER TABLE operations ADD COLUMN match_reason TEXT NOT NULL DEFAULT '';`, // 5: a link whose target already existed (only the original was retired); // undo must then leave that target alone. `ALTER TABLE operations ADD COLUMN link_preexisted INTEGER NOT NULL DEFAULT 0;`, // 6-11 belong to features that were removed again (tag editing, // organizing, the step-by-step fixer). They only add columns, which are // unused now, and stay so databases that already ran them keep the same // numbering and future migrations are never skipped. // 6: operation-specific details as JSON (e.g. which tags an edit changed). `ALTER TABLE operations ADD COLUMN detail TEXT NOT NULL DEFAULT '';`, // 7: file organizing (renaming files from their tags). `ALTER TABLE pairs ADD COLUMN organize_template TEXT NOT NULL DEFAULT ''; ALTER TABLE pairs ADD COLUMN organize_multidisc TEXT NOT NULL DEFAULT 'prefix'; ALTER TABLE pairs ADD COLUMN auto_organize TEXT NOT NULL DEFAULT 'off';`, // 8: zero-padded disc numbers (Disc 01) by default. `ALTER TABLE pairs ADD COLUMN organize_disc_digits INTEGER NOT NULL DEFAULT 2;`, // 9: Lidarr-style naming (artist/album folder and track formats) as JSON. `ALTER TABLE pairs ADD COLUMN organize_config TEXT NOT NULL DEFAULT '';`, // 10: library type (music or soundtracks) and the step-by-step fixer's progress. `ALTER TABLE pairs ADD COLUMN library_type TEXT NOT NULL DEFAULT 'music'; ALTER TABLE pairs ADD COLUMN fixer_state TEXT NOT NULL DEFAULT '';`, // 11: slave-only songs whose album or artist the master has (removed). `ALTER TABLE matches ADD COLUMN missing TEXT NOT NULL DEFAULT '';`, // 12: opt-in automatic removal of empty slave folders after each scan. // For a few hours this was numbered 11, so databases that ran it under // that number already have the column; see addsExistingColumn. `ALTER TABLE pairs ADD COLUMN auto_cleanup_empty INTEGER NOT NULL DEFAULT 0;`, } // addsExistingColumn reports whether a migration failed only because the // single column it adds is already there, so it counts as applied. func addsExistingColumn(m string, err error) bool { return strings.Count(strings.TrimSpace(m), ";") == 1 && strings.Contains(m, "ADD COLUMN") && strings.Contains(err.Error(), "duplicate column name") } // SchemaVersion is the newest database schema this build knows. func SchemaVersion() int { return len(migrations) } // 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 && !addsExistingColumn(migrations[i], err) { 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" }