Files
playlink/internal/store/store.go
T
cdnutterandClaude Opus 5.5 5f895cab6a Loosen approximate matching and link to the album version's folders
Matching:
- Prefer the studio album over singles, EPs and compilations when a song
  exists on several releases, including when the master also has the
  single at the slave's exact folder.
- Ignore release labels (Single Version, Remastered, Explicit, Deluxe)
  inside brackets or a trailing " - ..." part; "Clean" stays a version.
- Treat live/acoustic/remix/... album folders as different recordings;
  the strict top-folder fallback now respects this too.
- Add a last-resort library-wide search that only accepts candidates whose
  artist tag agrees with the slave file.

Linking:
- Cross-folder matches now mirror the master's path in the slave (e.g. a
  single moves into the album folder) and prune the emptied folder; a
  "keep in current folder" option preserves the old behaviour. Undo
  recreates and prunes folders accordingly.
- Operations record the match kind and reason, so undo (and promotes that
  rename the master file) restore the correct pairing instead of leaving
  the file unmatched until the next scan.

UI: rewrite the approximate matching description with Avenged Sevenfold
examples and clear safeguards, and show where linking will place a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:25:21 -07:00

186 lines
5.9 KiB
Go

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