// 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);`, // 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');`, } // 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" }