A new Tags tab browses either library; songs can be edited one at a time
or several at once (e.g. a whole album, where per-song fields are locked
and differing values are kept unless changed). Review's compare panel
links to the editor for either side.
Tags are read with ffprobe and written with ffmpeg (MP3/ID3, FLAC, M4A,
OGG, Opus). An existing tag is always written under the name the file
already uses, so edits replace values instead of duplicating them (e.g.
raw Vorbis ALBUMARTIST/TRACKNUMBER), totals follow the file's style
("3/12" or a separate TRACKTOTAL), stale alias tags (YEAR next to DATE)
are removed, other tags and cover art are kept, and no encoder tag is
added. Every written file is verified: values must read back and the
decoded audio must be bit-identical.
Hardlinks are respected: a file used by one library is replaced
atomically (old file to quarantine); a file shared with the other
library is updated in place so the link survives, after a verified
backup copy goes to quarantine, with an option to edit only one
library's copy instead. Edits are operations (old -> new values shown in
History) that can be undone; an interrupted in-place edit is rolled back
on startup. Dry run applies. Migration 6 stores operation details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
6.2 KiB
Go
193 lines
6.2 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 '';`,
|
|
|
|
// 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: operation-specific details as JSON (e.g. which tags an edit changed).
|
|
`ALTER TABLE operations ADD COLUMN detail 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"
|
|
}
|