Files
playlink/internal/store/store.go
T
cdnutterandClaude Opus 5.5 c8e7d14677 Fix pairs disappearing: renumber the auto-cleanup migration to 12
The reverted "missing from master" feature had shipped migration 11, so
databases that ran it skipped the new auto_cleanup_empty migration (also
11) and every pairs query failed, showing no libraries. Restore the old
11 as an unused placeholder like 6-10 and add the column as 12; a
database that ran it under the old number counts it as applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:44:57 -07:00

231 lines
8.2 KiB
Go

// 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"
}