Add database backups and two-digit disc numbers
Disc numbers: the organizer writes two-digit disc numbers by default
(Disc 01, 01-04 Title, {disc}), with an option for the plain style
(Disc 2, 2-04). Migration 8 adds the per-pair setting.
Backups (Settings -> Backups), similar to Sonarr:
- A backup is a zip with a consistent snapshot of the database (VACUUM
INTO, safe while running) and backup.json. Configurable folder,
schedule (off/1/3/7/14 days) and retention for scheduled backups;
manual backups are kept until deleted. List, download, delete.
- Restore from a listed backup or an uploaded zip: validated first
(integrity check, playlink tables, not a newer schema), staged next to
the live database, then the server restarts; at startup the staged
database is swapped in and the previous one kept as
playlink.db.pre-restore-<time>. Refused while a job runs. Backup names
are validated, so downloads/deletes cannot reach other files.
- Optional backup before every action in live mode: once per action the
user starts (link, promote, bulk, build/remove, tag edit, folder
renames, undo, purge) and before scans auto-link or auto-organize,
never per file and never in dry run. If the backup fails the action
does not run. The newest 10 action backups are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96ea856dfd
commit
49fc32096a
18 files changed
+1235
-47
No files matched your search
@@ -0,0 +1,434 @@
|
||||
// Package backup makes, lists and restores backups of playlink's database
|
||||
// (settings, library pairs, scan results and history, not music files).
|
||||
//
|
||||
// A backup is a zip holding a consistent snapshot of the database (SQLite
|
||||
// "VACUUM INTO", safe while the app runs) and a small backup.json. Restoring
|
||||
// stages the database next to the live one; it is swapped in at the next
|
||||
// start (see ApplyStaged), and the replaced database is kept.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeManual = "manual"
|
||||
TypeScheduled = "scheduled"
|
||||
TypeAction = "action" // taken automatically before an action in live mode
|
||||
|
||||
dbName = "playlink.db"
|
||||
stagedName = "playlink.db.restore"
|
||||
metaName = "backup.json"
|
||||
keepActions = 10
|
||||
)
|
||||
|
||||
var nameRe = regexp.MustCompile(`^playlink_backup_(manual|scheduled|action)_\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}(_\d+)?\.zip$`)
|
||||
|
||||
var ErrBadName = errors.New("not a playlink backup file name")
|
||||
|
||||
// Settings stored in the settings table.
|
||||
const (
|
||||
KeyFolder = "backup_folder"
|
||||
KeyIntervalDays = "backup_interval_days"
|
||||
KeyRetentionDays = "backup_retention_days"
|
||||
KeyBeforeActions = "backup_before_actions"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
st *store.Store
|
||||
dataDir string
|
||||
version string
|
||||
log *slog.Logger
|
||||
mu sync.Mutex // one backup or restore at a time
|
||||
}
|
||||
|
||||
func New(st *store.Store, dataDir, version string, log *slog.Logger) *Manager {
|
||||
return &Manager{st: st, dataDir: dataDir, version: version, log: log}
|
||||
}
|
||||
|
||||
// Config is the user-facing backup configuration.
|
||||
type Config struct {
|
||||
Folder string `json:"folder"`
|
||||
IntervalDays int `json:"interval_days"` // 0 = scheduled backups off
|
||||
RetentionDays int `json:"retention_days"` // scheduled backups older than this are removed
|
||||
BeforeActions bool `json:"before_actions"`
|
||||
}
|
||||
|
||||
func (m *Manager) Config(ctx context.Context) Config {
|
||||
c := Config{
|
||||
Folder: m.st.Setting(ctx, KeyFolder, filepath.Join(m.dataDir, "backups")),
|
||||
BeforeActions: m.st.Setting(ctx, KeyBeforeActions, "0") == "1",
|
||||
}
|
||||
c.IntervalDays, _ = strconv.Atoi(m.st.Setting(ctx, KeyIntervalDays, "7"))
|
||||
c.RetentionDays, _ = strconv.Atoi(m.st.Setting(ctx, KeyRetentionDays, "28"))
|
||||
return c
|
||||
}
|
||||
|
||||
func (m *Manager) SetConfig(ctx context.Context, c Config) error {
|
||||
c.Folder = filepath.Clean(strings.TrimSpace(c.Folder))
|
||||
if !filepath.IsAbs(c.Folder) {
|
||||
return errors.New("the backup folder must be an absolute path")
|
||||
}
|
||||
if c.IntervalDays < 0 || c.IntervalDays > 365 || c.RetentionDays < 1 || c.RetentionDays > 3650 {
|
||||
return errors.New("interval must be 0-365 days and retention 1-3650 days")
|
||||
}
|
||||
if err := os.MkdirAll(c.Folder, 0o750); err != nil {
|
||||
return fmt.Errorf("backup folder: %w", err)
|
||||
}
|
||||
probe := filepath.Join(c.Folder, ".playlink-write-test")
|
||||
if err := os.WriteFile(probe, nil, 0o600); err != nil {
|
||||
return fmt.Errorf("backup folder is not writable: %w", err)
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
before := "0"
|
||||
if c.BeforeActions {
|
||||
before = "1"
|
||||
}
|
||||
for k, v := range map[string]string{KeyFolder: c.Folder, KeyIntervalDays: strconv.Itoa(c.IntervalDays),
|
||||
KeyRetentionDays: strconv.Itoa(c.RetentionDays), KeyBeforeActions: before} {
|
||||
if err := m.st.SetSetting(ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup is one backup file.
|
||||
type Backup struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
type meta struct {
|
||||
App string `json:"app"`
|
||||
Version string `json:"version"`
|
||||
Schema int `json:"schema"`
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// Create writes a new backup and applies retention.
|
||||
func (m *Manager) Create(ctx context.Context, typ, reason string) (Backup, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cfg := m.Config(ctx)
|
||||
if err := os.MkdirAll(cfg.Folder, 0o750); err != nil {
|
||||
return Backup{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
name := fmt.Sprintf("playlink_backup_%s_%s.zip", typ, now.Format("2006.01.02_15.04.05"))
|
||||
for i := 2; fileExists(filepath.Join(cfg.Folder, name)); i++ {
|
||||
name = fmt.Sprintf("playlink_backup_%s_%s_%d.zip", typ, now.Format("2006.01.02_15.04.05"), i)
|
||||
}
|
||||
snap := filepath.Join(cfg.Folder, ".snapshot-"+strconv.FormatInt(now.UnixNano(), 10)+".db")
|
||||
defer os.Remove(snap)
|
||||
if _, err := m.st.DB.ExecContext(ctx, `VACUUM INTO ?`, snap); err != nil {
|
||||
return Backup{}, fmt.Errorf("snapshot: %w", err)
|
||||
}
|
||||
var schema int
|
||||
_ = m.st.DB.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema)
|
||||
tmp := filepath.Join(cfg.Folder, "."+name+".tmp")
|
||||
if err := writeZip(tmp, snap, meta{App: "playlink", Version: m.version, Schema: schema, Type: typ, Reason: reason, Created: now.Unix()}); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return Backup{}, err
|
||||
}
|
||||
final := filepath.Join(cfg.Folder, name)
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return Backup{}, err
|
||||
}
|
||||
fi, _ := os.Stat(final)
|
||||
m.log.Info("backup created", "name", name, "reason", reason)
|
||||
m.prune(cfg)
|
||||
return Backup{Name: name, Type: typ, Size: fi.Size(), Created: now.Unix()}, nil
|
||||
}
|
||||
|
||||
func writeZip(path, dbFile string, md meta) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
fail := func(e error) error { zw.Close(); f.Close(); return e }
|
||||
w, err := zw.Create(dbName)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
src, err := os.Open(dbFile)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
_, err = io.Copy(w, src)
|
||||
src.Close()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
w, err = zw.Create(metaName)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(md); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
|
||||
// List returns backups, newest first.
|
||||
func (m *Manager) List(ctx context.Context) ([]Backup, error) {
|
||||
cfg := m.Config(ctx)
|
||||
ents, err := os.ReadDir(cfg.Folder)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Backup{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Backup{}
|
||||
for _, en := range ents {
|
||||
sub := nameRe.FindStringSubmatch(en.Name())
|
||||
if sub == nil || !en.Type().IsRegular() {
|
||||
continue
|
||||
}
|
||||
fi, err := en.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Backup{Name: en.Name(), Type: sub[1], Size: fi.Size(), Created: createdOf(en.Name(), fi.ModTime())})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].Created > out[j].Created || (out[i].Created == out[j].Created && out[i].Name > out[j].Name)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var stampRe = regexp.MustCompile(`\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}`)
|
||||
|
||||
func createdOf(name string, fallback time.Time) int64 {
|
||||
if t, err := time.ParseInLocation("2006.01.02_15.04.05", stampRe.FindString(name), time.Local); err == nil {
|
||||
return t.Unix()
|
||||
}
|
||||
return fallback.Unix()
|
||||
}
|
||||
|
||||
// Path returns the absolute path of a backup, refusing anything that is not
|
||||
// a backup file name (no path traversal).
|
||||
func (m *Manager) Path(ctx context.Context, name string) (string, error) {
|
||||
if !nameRe.MatchString(name) {
|
||||
return "", ErrBadName
|
||||
}
|
||||
p := filepath.Join(m.Config(ctx).Folder, name)
|
||||
if !fileExists(p) {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(ctx context.Context, name string) error {
|
||||
p, err := m.Path(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
// prune removes scheduled backups older than the retention period and all
|
||||
// but the newest action backups. Manual backups are kept until deleted.
|
||||
func (m *Manager) prune(cfg Config) {
|
||||
list, err := m.List(context.Background())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(cfg.RetentionDays) * 24 * time.Hour).Unix()
|
||||
actions := 0
|
||||
for _, b := range list { // newest first
|
||||
remove := false
|
||||
switch b.Type {
|
||||
case TypeScheduled:
|
||||
remove = b.Created < cutoff
|
||||
case TypeAction:
|
||||
actions++
|
||||
remove = actions > keepActions
|
||||
}
|
||||
if remove {
|
||||
if err := os.Remove(filepath.Join(cfg.Folder, b.Name)); err == nil {
|
||||
m.log.Info("old backup removed", "name", b.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Due makes a scheduled backup when the interval has passed.
|
||||
func (m *Manager) Due(ctx context.Context) {
|
||||
cfg := m.Config(ctx)
|
||||
if cfg.IntervalDays <= 0 {
|
||||
return
|
||||
}
|
||||
list, err := m.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, b := range list {
|
||||
if b.Type == TypeScheduled {
|
||||
if time.Since(time.Unix(b.Created, 0)) < time.Duration(cfg.IntervalDays)*24*time.Hour {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := m.Create(ctx, TypeScheduled, "schedule"); err != nil {
|
||||
m.log.Warn("scheduled backup failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeAction makes an action backup when that option is on. An error means
|
||||
// the action must not run.
|
||||
func (m *Manager) BeforeAction(ctx context.Context, reason string) error {
|
||||
if !m.Config(ctx).BeforeActions {
|
||||
return nil
|
||||
}
|
||||
if _, err := m.Create(ctx, TypeAction, reason); err != nil {
|
||||
return fmt.Errorf("the backup before this action failed, so the action was not started: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- restore ----
|
||||
|
||||
// Stage validates a backup zip and places its database next to the live one,
|
||||
// to be swapped in at the next start.
|
||||
func (m *Manager) Stage(ctx context.Context, zipPath string, maxSchema int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("not a valid backup zip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
var dbf *zip.File
|
||||
for _, f := range zr.File {
|
||||
if f.Name == dbName {
|
||||
dbf = f
|
||||
}
|
||||
}
|
||||
if dbf == nil {
|
||||
return errors.New("the zip does not contain a playlink database")
|
||||
}
|
||||
tmp := filepath.Join(m.dataDir, stagedName+".tmp")
|
||||
_ = os.Remove(tmp)
|
||||
defer os.Remove(tmp)
|
||||
if err := extract(dbf, tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate(ctx, tmp, maxSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, filepath.Join(m.dataDir, stagedName)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.log.Warn("backup staged for restore; restarting to apply it", "from", filepath.Base(zipPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func extract(f *zip.File, dst string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Cap the size so a hostile zip cannot fill the disk.
|
||||
if _, err := io.Copy(out, io.LimitReader(rc, 20<<30)); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// validate opens a database file read-only and checks it is an intact
|
||||
// playlink database this version can use.
|
||||
func validate(ctx context.Context, path string, maxSchema int) error {
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
var ok string
|
||||
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
|
||||
return fmt.Errorf("the backup database is damaged (%v %s)", err, ok)
|
||||
}
|
||||
var schema int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema); err != nil {
|
||||
return errors.New("the backup is not a playlink database")
|
||||
}
|
||||
if schema < 1 || schema > maxSchema {
|
||||
return fmt.Errorf("the backup is from a newer version of playlink (schema %d, this version supports up to %d)", schema, maxSchema)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('pairs', 'users', 'operations')`).Scan(&n); err != nil || n != 3 {
|
||||
return errors.New("the backup is not a playlink database")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyStaged swaps a staged restore in before the database is opened. The
|
||||
// database it replaces is kept as playlink.db.pre-restore-<time>.
|
||||
func ApplyStaged(dataDir string, log *slog.Logger) error {
|
||||
staged := filepath.Join(dataDir, stagedName)
|
||||
if !fileExists(staged) {
|
||||
return nil
|
||||
}
|
||||
live := filepath.Join(dataDir, dbName)
|
||||
keep := live + ".pre-restore-" + time.Now().Format("2006.01.02_15.04.05")
|
||||
if fileExists(live) {
|
||||
if err := os.Rename(live, keep); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sfx := range []string{"-wal", "-shm"} {
|
||||
if fileExists(live + sfx) {
|
||||
_ = os.Rename(live+sfx, keep+sfx)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.Rename(staged, live); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Warn("database restored from backup", "previous database kept as", filepath.Base(keep))
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user