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:
cdnutterandClaude Opus 5.5 committed 2026-09-29 14:26:34 -07:00
1 parent 96ea856dfd
commit 49fc32096a
18 files changed
+1235 -47

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/go-chi/chi/v5"
"github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/store"
)
func (s *Server) backupRoutes(r chi.Router) {
r.Get("/backups", s.handleListBackups)
r.Put("/backups/config", s.handleBackupConfig)
r.Post("/backups", s.handleCreateBackup)
r.Post("/backups/restore-upload", s.handleRestoreUpload)
r.Get("/backups/{name}/download", s.handleDownloadBackup)
r.Post("/backups/{name}/restore", s.handleRestoreBackup)
r.Delete("/backups/{name}", s.handleDeleteBackup)
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
list, err := s.cfg.Backups.List(r.Context())
if err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, map[string]any{"config": s.cfg.Backups.Config(r.Context()), "backups": list})
}
func (s *Server) handleBackupConfig(w http.ResponseWriter, r *http.Request) {
var c backup.Config
if !readJSON(w, r, &c) {
return
}
if err := s.cfg.Backups.SetConfig(r.Context(), c); err != nil {
writeErr(w, 400, err)
return
}
writeJSON(w, 200, s.cfg.Backups.Config(r.Context()))
}
func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
b, err := s.cfg.Backups.Create(r.Context(), backup.TypeManual, "manual")
if err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, b)
}
func (s *Server) backupPath(w http.ResponseWriter, r *http.Request) (string, bool) {
p, err := s.cfg.Backups.Path(r.Context(), chi.URLParam(r, "name"))
switch {
case errors.Is(err, backup.ErrBadName):
writeErr(w, 400, err)
case err != nil:
writeErr(w, 404, errors.New("backup not found"))
default:
return p, true
}
return "", false
}
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
p, ok := s.backupPath(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(p)))
http.ServeFile(w, r, p)
}
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
if _, ok := s.backupPath(w, r); !ok {
return
}
if err := s.cfg.Backups.Delete(r.Context(), chi.URLParam(r, "name")); err != nil {
writeErr(w, 500, err)
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// restoreBusy refuses a restore while any job runs: the database is about to
// be replaced.
func (s *Server) restoreBusy(w http.ResponseWriter, r *http.Request) bool {
pairs, _ := s.st.Pairs(r.Context())
for _, p := range pairs {
if j := s.eng.Job(p.ID); j != nil && j.State == "running" {
writeErr(w, 409, fmt.Errorf("a job is running for %q; wait for it to finish before restoring", p.Name))
return true
}
}
return false
}
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
p, ok := s.backupPath(w, r)
if !ok || s.restoreBusy(w, r) {
return
}
s.stageAndRestart(w, r, p)
}
func (s *Server) handleRestoreUpload(w http.ResponseWriter, r *http.Request) {
if s.restoreBusy(w, r) {
return
}
file, _, err := r.FormFile("backup")
if err != nil {
writeErr(w, 400, errors.New("choose a backup .zip file to upload"))
return
}
defer file.Close()
tmp, err := os.CreateTemp(s.cfg.Backups.Config(r.Context()).Folder, ".upload-*.zip")
if err != nil {
writeErr(w, 500, err)
return
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
writeErr(w, 400, err)
return
}
tmp.Close()
s.stageAndRestart(w, r, tmp.Name())
}
func (s *Server) stageAndRestart(w http.ResponseWriter, r *http.Request, zipPath string) {
if err := s.cfg.Backups.Stage(r.Context(), zipPath, store.SchemaVersion()); err != nil {
writeErr(w, 400, err)
return
}
writeJSON(w, 202, map[string]any{"ok": true, "restarting": true})
if s.cfg.Restart != nil {
// Let the response reach the browser before shutting down.
go func() {
time.Sleep(500 * time.Millisecond)
s.log.Warn("restarting to apply the restored backup")
s.cfg.Restart()
}()
}
}
+61 -4
View File
@@ -19,6 +19,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store"
@@ -28,6 +29,8 @@ import (
type Config struct {
SecureCookies bool
Version string
Backups *backup.Manager
Restart func() // stops the server so the process restarts (after a restore)
}
type Server struct {
@@ -54,7 +57,11 @@ func (s *Server) Handler() http.Handler {
r.Use(s.csrf)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
limit := int64(1 << 20)
if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") {
limit = 20 << 30 // backup zips can be large
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
@@ -72,6 +79,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/settings", s.handleGetSettings)
r.Put("/settings", s.handlePutSettings)
r.Get("/browse", s.handleBrowse)
if s.cfg.Backups != nil {
s.backupRoutes(r)
}
r.Get("/pairs", s.handleListPairs)
r.Post("/pairs", s.handleSavePair)
@@ -340,6 +350,7 @@ type pairInput struct {
OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"`
AutoOrganize string `json:"auto_organize"`
OrganizeDiscDigits int `json:"organize_disc_digits"`
}
func (in pairInput) apply(p *store.Pair) {
@@ -348,6 +359,7 @@ func (in pairInput) apply(p *store.Pair) {
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize = in.OrganizeTemplate, in.OrganizeMultiDisc, in.AutoOrganize
p.OrganizeDiscDigits = in.OrganizeDiscDigits
}
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
@@ -469,6 +481,9 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if !s.preAction(w, r, id, "link identical") {
return
}
j, err := s.eng.StartLinkIdentical(id)
if err != nil {
opErr(w, err)
@@ -492,14 +507,14 @@ type pathsBody struct {
}
func (s *Server) handleBuild(w http.ResponseWriter, r *http.Request) {
s.pathsJob(w, r, s.eng.StartBuild)
s.pathsJob(w, r, "build", s.eng.StartBuild)
}
func (s *Server) handleRemove(w http.ResponseWriter, r *http.Request) {
s.pathsJob(w, r, s.eng.StartRemove)
s.pathsJob(w, r, "remove from slave", s.eng.StartRemove)
}
func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int64, []string) (*engine.Job, error)) {
func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, reason string, start func(int64, []string) (*engine.Job, error)) {
id, _ := idParam(r)
var body pathsBody
if !readJSON(w, r, &body) {
@@ -515,6 +530,9 @@ func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int
return
}
}
if !s.preAction(w, r, id, reason) {
return
}
j, err := start(id, body.Paths)
if err != nil {
opErr(w, err)
@@ -534,6 +552,9 @@ func (s *Server) handleLink(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, 0, "link") {
return
}
op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade, KeepFolder: body.KeepFolder})
if err != nil {
opErr(w, err)
@@ -550,6 +571,9 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) {
return
}
if body.Confirm && !s.preAction(w, r, 0, "promote") {
return
}
op, err := s.eng.Promote(r.Context(), id, body.Confirm)
if err != nil {
opErr(w, err)
@@ -590,6 +614,9 @@ func (s *Server) handleOps(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if !s.preAction(w, r, 0, "undo") {
return
}
if err := s.eng.Undo(r.Context(), id); err != nil {
opErr(w, err)
return
@@ -599,6 +626,9 @@ func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
if !s.preAction(w, r, 0, "purge quarantine") {
return
}
if err := s.eng.Purge(r.Context(), id); err != nil {
opErr(w, err)
return
@@ -653,6 +683,9 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, 200, map[string]any{"changed": n})
default:
if !s.preAction(w, r, id, "bulk "+b.Action) {
return
}
j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions)
if err != nil {
opErr(w, err)
@@ -690,6 +723,9 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
return
}
}
if !s.preAction(w, r, id, "rename folders") {
return
}
j, err := s.eng.StartRenames(id, body.Only)
if err != nil {
opErr(w, err)
@@ -735,6 +771,9 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) {
return
}
if !s.preAction(w, r, id, "edit tags") {
return
}
j, err := s.eng.StartTagEdit(id, body)
if err != nil {
opErr(w, err)
@@ -743,3 +782,21 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate)
writeJSON(w, 202, j)
}
// preAction runs the optional database backup before an action that changes
// files (live mode only). It refuses up front when the pair is busy, so no
// backup is made for an action that could not start.
func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64, reason string) bool {
if pairID != 0 {
if j := s.eng.Job(pairID); j != nil && j.State == "running" {
writeErr(w, 409, engine.ErrBusy)
return false
}
}
if err := s.eng.BeforeAction(r.Context(), reason); err != nil {
s.log.Warn("pre-action backup failed", "action", reason, "err", err)
writeErr(w, 500, err)
return false
}
return true
}
+434
View File
@@ -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
}
+170
View File
@@ -0,0 +1,170 @@
package backup
import (
"archive/zip"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cdnutter/playlink/internal/store"
)
var ctx = context.Background()
func setup(t *testing.T) (*store.Store, *Manager, string) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.DB.Close() })
return st, New(st, dir, "test", slog.New(slog.NewTextHandler(io.Discard, nil))), dir
}
func TestCreateListDownloadPath(t *testing.T) {
st, m, _ := setup(t)
st.SetSetting(ctx, "marker", "before")
b, err := m.Create(ctx, TypeManual, "manual")
if err != nil {
t.Fatal(err)
}
list, _ := m.List(ctx)
if len(list) != 1 || list[0].Name != b.Name || list[0].Type != TypeManual || list[0].Size == 0 {
t.Fatalf("list: %+v", list)
}
p, err := m.Path(ctx, b.Name)
if err != nil {
t.Fatal(err)
}
zr, err := zip.OpenReader(p)
if err != nil {
t.Fatal(err)
}
names := []string{}
for _, f := range zr.File {
names = append(names, f.Name)
}
zr.Close()
if strings.Join(names, ",") != "playlink.db,backup.json" {
t.Fatalf("zip contents: %v", names)
}
for _, bad := range []string{"../playlink.db", "evil.zip", "playlink_backup_manual_x.zip", "playlink_backup_manual_2026.01.01_00.00.00.zip/../../x"} {
if _, err := m.Path(ctx, bad); !errors.Is(err, ErrBadName) {
t.Errorf("%q must be refused, got %v", bad, err)
}
}
}
func TestRestoreRoundTrip(t *testing.T) {
st, m, dir := setup(t)
st.SetSetting(ctx, "marker", "before")
b, _ := m.Create(ctx, TypeManual, "manual")
st.SetSetting(ctx, "marker", "after")
p, _ := m.Path(ctx, b.Name)
if err := m.Stage(ctx, p, store.SchemaVersion()); err != nil {
t.Fatal(err)
}
st.DB.Close()
if err := ApplyStaged(dir, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatal(err)
}
st2, err := store.Open(dir)
if err != nil {
t.Fatal(err)
}
defer st2.DB.Close()
if got := st2.Setting(ctx, "marker", ""); got != "before" {
t.Fatalf("restore should bring back the old value, got %q", got)
}
kept, _ := filepath.Glob(filepath.Join(dir, "playlink.db.pre-restore-*"))
if len(kept) == 0 {
t.Fatal("the replaced database must be kept")
}
}
func TestStageRejectsBadBackups(t *testing.T) {
_, m, dir := setup(t)
notZip := filepath.Join(dir, "x.zip")
os.WriteFile(notZip, []byte("nope"), 0o644)
if err := m.Stage(ctx, notZip, store.SchemaVersion()); err == nil {
t.Error("a non-zip must be rejected")
}
mk := func(name string, content []byte) string {
p := filepath.Join(dir, name)
f, _ := os.Create(p)
zw := zip.NewWriter(f)
w, _ := zw.Create("playlink.db")
w.Write(content)
zw.Close()
f.Close()
return p
}
if err := m.Stage(ctx, mk("junk.zip", []byte("not a database")), store.SchemaVersion()); err == nil {
t.Error("a damaged database must be rejected")
}
b, _ := m.Create(ctx, TypeManual, "manual")
p, _ := m.Path(ctx, b.Name)
if err := m.Stage(ctx, p, 1); err == nil || !strings.Contains(err.Error(), "newer version") {
t.Errorf("a backup from a newer schema must be rejected, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, stagedName)); err == nil {
t.Error("nothing may be staged after a rejected backup")
}
}
func TestRetentionScheduleAndBeforeAction(t *testing.T) {
_, m, _ := setup(t)
cfg := m.Config(ctx)
if cfg.IntervalDays != 7 || cfg.RetentionDays != 28 || cfg.BeforeActions {
t.Fatalf("defaults: %+v", cfg)
}
// Off by default: no backup before actions.
if err := m.BeforeAction(ctx, "link"); err != nil {
t.Fatal(err)
}
if l, _ := m.List(ctx); len(l) != 0 {
t.Fatal("no action backup while the option is off")
}
cfg.BeforeActions = true
if err := m.SetConfig(ctx, cfg); err != nil {
t.Fatal(err)
}
for i := 0; i < 12; i++ {
if err := m.BeforeAction(ctx, "link"); err != nil {
t.Fatal(err)
}
}
l, _ := m.List(ctx)
if len(l) != keepActions {
t.Fatalf("only the newest %d action backups are kept, got %d", keepActions, len(l))
}
// An old scheduled backup is removed by retention; Due makes a new one.
old := filepath.Join(cfg.Folder, "playlink_backup_scheduled_"+time.Now().AddDate(0, 0, -60).Format("2006.01.02_15.04.05")+".zip")
os.WriteFile(old, []byte("x"), 0o644)
m.Due(ctx)
if _, err := os.Stat(old); !errors.Is(err, os.ErrNotExist) {
t.Error("scheduled backups past retention must be removed")
}
sched := 0
l, _ = m.List(ctx)
for _, b := range l {
if b.Type == TypeScheduled {
sched++
}
}
m.Due(ctx) // not due again yet
l2, _ := m.List(ctx)
if sched != 1 || len(l2) != len(l) {
t.Fatalf("want exactly one scheduled backup, got %d (then %d total vs %d)", sched, len(l2), len(l))
}
if err := m.SetConfig(ctx, Config{Folder: "relative/path", IntervalDays: 1, RetentionDays: 1}); err == nil {
t.Error("a relative backup folder must be rejected")
}
}
+19 -2
View File
@@ -42,8 +42,9 @@ type Engine struct {
// the same files.
mu sync.Mutex
tagTools tags.Tools
tagCache tagCache
tagTools tags.Tools
beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
jobsMu sync.Mutex
jobs map[int64]*Job
@@ -358,3 +359,19 @@ func sortedKeys(m map[string]fsops.Info) []string {
sort.Strings(keys)
return keys
}
// SetBeforeAction installs the hook that backs up the database before an
// action in live mode.
func (e *Engine) SetBeforeAction(f func(ctx context.Context, reason string) error) {
e.beforeAction = f
}
// BeforeAction runs the pre-action backup, once per user action (not per
// file). It does nothing in dry run, where no files change. An error means
// the action must not run.
func (e *Engine) BeforeAction(ctx context.Context, reason string) error {
if e.beforeAction == nil || e.st.DryRun(ctx) {
return nil
}
return e.beforeAction(ctx, reason)
}
+37
View File
@@ -528,3 +528,40 @@ func TestPromoteRenamedMasterStaysLinked(t *testing.T) {
t.Fatalf("undo should restore the original match, got %s -> %q", got.Status, got.RelMaster)
}
}
// The pre-action backup runs once per action (not per file) and never in
// dry run.
func TestBeforeActionOncePerScan(t *testing.T) {
ev := setup(t, false)
calls := 0
ev.e.SetBeforeAction(func(context.Context, string) error { calls++; return nil })
ev.pair.AutoLinkIdentical = true
ev.pair.SettleMinutes = 0
ev.st.SavePair(ctx, &ev.pair)
for i := 0; i < 5; i++ {
rel := filepath.Join("A/B", string(rune('a'+i))+".flac")
ev.write(ev.master, rel, "same")
ev.write(ev.slave, rel, "same")
}
ev.scan()
if calls != 1 {
t.Fatalf("auto-linking 5 files should back up once, got %d", calls)
}
ev.st.SetSetting(ctx, "dry_run", "1")
if err := ev.e.BeforeAction(ctx, "link"); err != nil || calls != 1 {
t.Fatal("no backup in dry run")
}
ev.st.SetSetting(ctx, "dry_run", "0")
ev.e.SetBeforeAction(func(context.Context, string) error { return errors.New("disk full") })
ev.write(ev.master, "A/B/z.flac", "same")
ev.write(ev.slave, "A/B/z.flac", "same")
if _, err := ev.e.StartScan(ev.pair.ID, "test"); err != nil {
t.Fatal(err)
}
for ev.e.Job(ev.pair.ID).State == "running" {
time.Sleep(10 * time.Millisecond)
}
if sameInode(t, filepath.Join(ev.master, "A/B/z.flac"), filepath.Join(ev.slave, "A/B/z.flac")) {
t.Fatal("a failed backup must stop the action")
}
}
+19 -5
View File
@@ -76,6 +76,13 @@ func normalizeOrganize(p *store.Pair) error {
default:
return errors.New("multi-disc style must be prefix, folder or none")
}
switch p.OrganizeDiscDigits {
case 0:
p.OrganizeDiscDigits = 2
case 1, 2:
default:
return errors.New("disc numbers must use 1 or 2 digits")
}
switch p.AutoOrganize {
case "":
p.AutoOrganize = "off"
@@ -130,7 +137,11 @@ func leadingNumber(s string) int {
// renderName builds the organized path for a file from its tags. It returns
// the missing tag when a required one is empty.
func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string) {
func renderName(tpl, multiDisc string, discDigits int, ext string, f map[string]string) (string, string) {
if discDigits < 1 {
discDigits = 2
}
discStr := func(d int) string { return fmt.Sprintf("%0*d", discDigits, d) }
artist := f[tags.AlbumArtist]
if strings.TrimSpace(artist) == "" {
artist = f[tags.Artist]
@@ -147,7 +158,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
}
trackStr = fmt.Sprintf("%0*d", width, track)
if multi && multiDisc == "prefix" && disc > 0 {
trackStr = fmt.Sprintf("%d-%s", disc, trackStr)
trackStr = discStr(disc) + "-" + trackStr
}
}
year := ""
@@ -156,7 +167,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
}
values := map[string]string{
"artist": artist, "track_artist": f[tags.Artist], "album": f[tags.Album], "track": trackStr,
"title": f[tags.Title], "disc": strconv.Itoa(disc), "year": year, "genre": f[tags.Genre],
"title": f[tags.Title], "disc": discStr(disc), "year": year, "genre": f[tags.Genre],
}
if disc == 0 {
values["disc"] = ""
@@ -188,7 +199,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
}
if multi && multiDisc == "folder" && disc > 0 {
last := len(parts) - 1
parts = append(parts[:last], fmt.Sprintf("Disc %d", disc), parts[last])
parts = append(parts[:last], "Disc "+discStr(disc), parts[last])
}
return filepath.Join(parts...) + strings.ToLower(ext), ""
}
@@ -293,7 +304,7 @@ func (e *Engine) planOrganize(ctx context.Context, j *Job, l *libs, sides []stri
item.Reason = "file type without editable tags"
} else if fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(root.Path, rel), in); err != nil {
item.Reason = "tags could not be read"
} else if to, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, filepath.Ext(rel), fields); missing != "" {
} else if to, missing := renderName(l.pair.OrganizeTemplate, l.pair.OrganizeMultiDisc, l.pair.OrganizeDiscDigits, filepath.Ext(rel), fields); missing != "" {
item.Reason = "missing " + missing + " tag"
} else if to == rel {
plan.Conforming++
@@ -616,6 +627,9 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
if len(moves) == 0 {
return nil
}
if err := e.BeforeAction(ctx, "auto-organize"); err != nil {
return err
}
e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
return e.applyOrganize(ctx, j, l, moves, "auto-organize")
}
+15 -12
View File
@@ -20,23 +20,26 @@ func TestRenderName(t *testing.T) {
}
cases := []struct {
name, multi, ext string
digits int
fields map[string]string
want, missing string
}{
{"basic", "prefix", ".mp3", f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
{"album artist wins", "prefix", ".flac", f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
{"no track number", "prefix", ".mp3", f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
{"missing album", "prefix", ".mp3", f("artist", "A", "title", "Song"), "", "album"},
{"missing title", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "1"), "", "title"},
{"multi-disc prefix", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
{"multi-disc folder", "folder", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
{"single disc of one", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
{"unsafe characters", "prefix", ".MP3", f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
{"no hidden names", "prefix", ".mp3", f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
{"big albums use 3 digits", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
{"basic", "prefix", ".mp3", 0, f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.mp3", ""},
{"album artist wins", "prefix", ".flac", 0, f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""},
{"no track number", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
{"missing album", "prefix", ".mp3", 0, f("artist", "A", "title", "Song"), "", "album"},
{"missing title", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "1"), "", "title"},
{"multi-disc prefix", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/02-04 S.mp3", ""},
{"multi-disc folder", "folder", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 02/04 S.mp3", ""},
{"single disc of one", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/04 S.mp3", ""},
{"unsafe characters", "prefix", ".MP3", 0, f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.mp3", ""},
{"no hidden names", "prefix", ".mp3", 0, f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""},
{"unpadded disc prefix", "prefix", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "disc_total", "2", "title", "S"), "A/B/2-04 S.mp3", ""},
{"unpadded disc folder", "folder", ".mp3", 1, f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/04 S.mp3", ""},
{"big albums use 3 digits", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "track", "7", "track_total", "120", "title", "S"), "A/B/007 S.mp3", ""},
}
for _, c := range cases {
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.ext, c.fields)
got, missing := renderName(DefaultOrganizeTemplate, c.multi, c.digits, c.ext, c.fields)
if got != c.want || missing != c.missing {
t.Errorf("%s: got %q (missing %q), want %q (missing %q)", c.name, got, missing, c.want, c.missing)
}
+3
View File
@@ -395,6 +395,9 @@ func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string)
})
return nil
}
if err := e.BeforeAction(ctx, "auto-link"); err != nil {
return err
}
return e.linkIDs(ctx, j, l.pair.ID, ids, "auto:"+trigger, true)
}
+11 -9
View File
@@ -150,8 +150,9 @@ type Pair struct {
ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` // percent
OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
AutoOrganize string `json:"auto_organize"` // off, slave, master or both
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
AutoOrganize string `json:"auto_organize"` // off, slave, master or both
OrganizeDiscDigits int `json:"organize_disc_digits"` // 2: Disc 01, 1: Disc 1
LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"`
@@ -160,7 +161,7 @@ type Pair struct {
const pairCols = `id, name, master_root, slave_root, quarantine_dir, extensions, auto_link_identical,
scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold, organize_template,
organize_multidisc, auto_organize, last_scan_at, last_scan_error,
organize_multidisc, auto_organize, organize_disc_digits, last_scan_at, last_scan_error,
last_scan_warnings, created_at`
type scanner interface{ Scan(...any) error }
@@ -171,7 +172,7 @@ func scanPair(r scanner) (Pair, error) {
var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical,
&p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &p.ApproxMatching, &p.ApproxThreshold, &p.OrganizeTemplate,
&p.OrganizeMultiDisc, &p.AutoOrganize, &last, &p.LastScanError,
&p.OrganizeMultiDisc, &p.AutoOrganize, &p.OrganizeDiscDigits, &last, &p.LastScanError,
&warns, &p.CreatedAt)
if err != nil {
return p, err
@@ -214,11 +215,11 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
if p.ID == 0 {
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions,
auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
organize_template, organize_multidisc, auto_organize, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
organize_template, organize_multidisc, auto_organize, organize_disc_digits, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, now())
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, now())
if err != nil {
return err
}
@@ -227,10 +228,11 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
}
_, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, master_root = ?, slave_root = ?, quarantine_dir = ?,
extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ?,
approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ? WHERE id = ?`,
approx_matching = ?, approx_threshold = ?, organize_template = ?, organize_multidisc = ?, auto_organize = ?,
organize_disc_digits = ? WHERE id = ?`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold,
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.ID)
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize, p.OrganizeDiscDigits, p.ID)
return err
}
+6
View File
@@ -126,8 +126,14 @@ var migrations = []string{
`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;`,
}
// 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") +