Naming: separate artist folder, album folder, track and multi-disc track
formats with presets, Lidarr-style tokens ({Artist Name}, {Artist
NameThe}, {Artist CleanName}, {Artist NameFirstCharacter}, {Album
Title}, {Album TitleThe}, {Album CleanTitle}, {Release Year},
{track:00}, {medium:00}, {Medium Format}, {Track Title}, {Track
CleanTitle}, {Track ArtistName}, {Genre}) with Lidarr's word-separator
and case variants, colon replacement modes and replace/remove for other
illegal characters. Empty brackets and separators left by missing values
are cleaned up. Defaults to Artist/Album/01 Title with Disc 01 folders
for multi-disc albums; earlier pattern/disc settings are converted.
Examples are now rendered by the server with the same code as real
renames, fixing the settings page showing paths that differed from what
would happen (e.g. disc 1 of a multi-disc album outside its Disc folder).
"Rename now..." checks one or both libraries, then lists every change
grouped by destination folder with per-file and per-folder selection,
skipped files with reasons and a Fix tags button, and warnings; applying
re-checks each file, renames, and rescans. The pre-action backup runs
first when enabled. Migration 9 stores the naming as JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
6.9 KiB
Go
207 lines
6.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 '';`,
|
|
|
|
// 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 '';`,
|
|
|
|
// 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 '';`,
|
|
}
|
|
|
|
// 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 {
|
|
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"
|
|
}
|