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

+9 -1
View File
@@ -86,7 +86,7 @@ tags** buttons. Edits are safe with hardlinks:
**Organize (rename files from tags).** The *Organize* tab sets a naming layout, by default `{artist}/{album}/{track} {title}` → **Organize (rename files from tags).** The *Organize* tab sets a naming layout, by default `{artist}/{album}/{track} {title}` →
`Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to the artist). Tokens: `{artist}`, `Avenged Sevenfold/Nightmare/01 Nightmare.flac` (`{artist}` is the album artist, falling back to the artist). Tokens: `{artist}`,
`{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get a disc prefix (`2-04 Title`) or `{album}`, `{track}`, `{title}`, `{year}`, `{disc}`, `{track_artist}`, `{genre}`. Multi-disc albums get a disc prefix (`2-04 Title`) or
a `Disc 2` folder, and names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → `Title - Sub`). a `Disc 02` folder (two-digit disc numbers by default, optionally `Disc 2`), and names are made safe for Windows/SMB shares (`AC/DC` → `AC-DC`, `Title: Sub` → `Title - Sub`).
With **Organize automatically** set to the slave, the master or both (off by default), every scan renames files that don't follow the With **Organize automatically** set to the slave, the master or both (off by default), every scan renames files that don't follow the
layout. Files are only moved within their library, so contents and hardlinks never change; lyrics (`.lrc`) move with their song, cover layout. Files are only moved within their library, so contents and hardlinks never change; lyrics (`.lrc`) move with their song, cover
@@ -94,6 +94,14 @@ art follows a whole folder, and emptied folders are removed. Files with missing
in the *Tags* tab). Each rename is in History and can be undone, and dry run applies. A slave file that is a hardlink of a master file in the *Tags* tab). Each rename is in History and can be undone, and dry run applies. A slave file that is a hardlink of a master file
at a different path is still recognised as linked. at a different path is still recognised as linked.
**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and
history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7
or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be
downloaded, deleted, or restored, also from an uploaded zip. A restore is validated first (damaged backups and ones from a newer version
are refused), then playlink restarts to swap the database in and keeps the replaced one as `playlink.db.pre-restore-…`; Docker's restart
policy brings it back automatically. Optionally, **back up before every action in live mode**: one backup per action you start (and
before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept.
**Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan. **Export.** The Review tab's **Export CSV** button downloads either the current view (filter and search applied) or the entire scan.
Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate, Each row has the match status and type, a recommendation, both paths, where linking would place the file, codec, bitrate, sample rate,
bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
+17 -3
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/cdnutter/playlink/internal/api" "github.com/cdnutter/playlink/internal/api"
"github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/probe" "github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/store"
@@ -82,6 +83,11 @@ func main() {
log.Error("create data dir", "err", err) log.Error("create data dir", "err", err)
os.Exit(1) os.Exit(1)
} }
// A restore chosen in the UI is swapped in before the database opens.
if err := backup.ApplyStaged(dataDir, log); err != nil {
log.Error("apply restored backup", "err", err)
os.Exit(1)
}
st, err := store.Open(dataDir) st, err := store.Open(dataDir)
if err != nil { if err != nil {
log.Error("open database", "err", err) log.Error("open database", "err", err)
@@ -99,12 +105,19 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
backups := backup.New(st, dataDir, version, log)
eng.SetBeforeAction(backups.BeforeAction)
// Restoring a backup restarts the process (Docker's restart policy
// brings it back) so the database is swapped while nothing uses it.
ctx, restart := context.WithCancel(ctx)
defer restart()
eng.Recover(ctx) eng.Recover(ctx)
go scheduler(ctx, st, eng, log) go scheduler(ctx, st, eng, backups, log)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: api.New(api.Config{SecureCookies: secure, Version: version}, st, eng, log, web.Dist()).Handler(), Handler: api.New(api.Config{SecureCookies: secure, Version: version, Backups: backups, Restart: restart}, st, eng, log, web.Dist()).Handler(),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute, WriteTimeout: 5 * time.Minute,
@@ -125,12 +138,13 @@ func main() {
} }
// scheduler starts interval scans and hourly maintenance. // scheduler starts interval scans and hourly maintenance.
func scheduler(ctx context.Context, st *store.Store, eng *engine.Engine, log *slog.Logger) { func scheduler(ctx context.Context, st *store.Store, eng *engine.Engine, backups *backup.Manager, log *slog.Logger) {
tick := time.NewTicker(time.Minute) tick := time.NewTicker(time.Minute)
defer tick.Stop() defer tick.Stop()
lastMaint := time.Time{} lastMaint := time.Time{}
for { for {
if time.Since(lastMaint) >= time.Hour { if time.Since(lastMaint) >= time.Hour {
backups.Due(ctx)
eng.Maintenance(ctx) eng.Maintenance(ctx)
lastMaint = time.Now() lastMaint = time.Now()
} }
+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"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/cdnutter/playlink/internal/backup"
"github.com/cdnutter/playlink/internal/engine" "github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/fsops" "github.com/cdnutter/playlink/internal/fsops"
"github.com/cdnutter/playlink/internal/store" "github.com/cdnutter/playlink/internal/store"
@@ -28,6 +29,8 @@ import (
type Config struct { type Config struct {
SecureCookies bool SecureCookies bool
Version string Version string
Backups *backup.Manager
Restart func() // stops the server so the process restarts (after a restore)
} }
type Server struct { type Server struct {
@@ -54,7 +57,11 @@ func (s *Server) Handler() http.Handler {
r.Use(s.csrf) r.Use(s.csrf)
r.Use(func(next http.Handler) http.Handler { r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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") w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
@@ -72,6 +79,9 @@ func (s *Server) Handler() http.Handler {
r.Get("/settings", s.handleGetSettings) r.Get("/settings", s.handleGetSettings)
r.Put("/settings", s.handlePutSettings) r.Put("/settings", s.handlePutSettings)
r.Get("/browse", s.handleBrowse) r.Get("/browse", s.handleBrowse)
if s.cfg.Backups != nil {
s.backupRoutes(r)
}
r.Get("/pairs", s.handleListPairs) r.Get("/pairs", s.handleListPairs)
r.Post("/pairs", s.handleSavePair) r.Post("/pairs", s.handleSavePair)
@@ -340,6 +350,7 @@ type pairInput struct {
OrganizeTemplate string `json:"organize_template"` OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"` OrganizeMultiDisc string `json:"organize_multidisc"`
AutoOrganize string `json:"auto_organize"` AutoOrganize string `json:"auto_organize"`
OrganizeDiscDigits int `json:"organize_disc_digits"`
} }
func (in pairInput) apply(p *store.Pair) { 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.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize = in.OrganizeTemplate, in.OrganizeMultiDisc, in.AutoOrganize 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) { 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) { func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r) id, _ := idParam(r)
if !s.preAction(w, r, id, "link identical") {
return
}
j, err := s.eng.StartLinkIdentical(id) j, err := s.eng.StartLinkIdentical(id)
if err != nil { if err != nil {
opErr(w, err) opErr(w, err)
@@ -492,14 +507,14 @@ type pathsBody struct {
} }
func (s *Server) handleBuild(w http.ResponseWriter, r *http.Request) { 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) { 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) id, _ := idParam(r)
var body pathsBody var body pathsBody
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
@@ -515,6 +530,9 @@ func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int
return return
} }
} }
if !s.preAction(w, r, id, reason) {
return
}
j, err := start(id, body.Paths) j, err := start(id, body.Paths)
if err != nil { if err != nil {
opErr(w, err) opErr(w, err)
@@ -534,6 +552,9 @@ func (s *Server) handleLink(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
return 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}) op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade, KeepFolder: body.KeepFolder})
if err != nil { if err != nil {
opErr(w, err) opErr(w, err)
@@ -550,6 +571,9 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
return return
} }
if body.Confirm && !s.preAction(w, r, 0, "promote") {
return
}
op, err := s.eng.Promote(r.Context(), id, body.Confirm) op, err := s.eng.Promote(r.Context(), id, body.Confirm)
if err != nil { if err != nil {
opErr(w, err) 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) { func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r) id, _ := idParam(r)
if !s.preAction(w, r, 0, "undo") {
return
}
if err := s.eng.Undo(r.Context(), id); err != nil { if err := s.eng.Undo(r.Context(), id); err != nil {
opErr(w, err) opErr(w, err)
return 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) { func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r) id, _ := idParam(r)
if !s.preAction(w, r, 0, "purge quarantine") {
return
}
if err := s.eng.Purge(r.Context(), id); err != nil { if err := s.eng.Purge(r.Context(), id); err != nil {
opErr(w, err) opErr(w, err)
return return
@@ -653,6 +683,9 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, 200, map[string]any{"changed": n}) writeJSON(w, 200, map[string]any{"changed": n})
default: default:
if !s.preAction(w, r, id, "bulk "+b.Action) {
return
}
j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions) j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions)
if err != nil { if err != nil {
opErr(w, err) opErr(w, err)
@@ -690,6 +723,9 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if !s.preAction(w, r, id, "rename folders") {
return
}
j, err := s.eng.StartRenames(id, body.Only) j, err := s.eng.StartRenames(id, body.Only)
if err != nil { if err != nil {
opErr(w, err) opErr(w, err)
@@ -735,6 +771,9 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &body) { if !readJSON(w, r, &body) {
return return
} }
if !s.preAction(w, r, id, "edit tags") {
return
}
j, err := s.eng.StartTagEdit(id, body) j, err := s.eng.StartTagEdit(id, body)
if err != nil { if err != nil {
opErr(w, err) 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) 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) 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. // the same files.
mu sync.Mutex mu sync.Mutex
tagTools tags.Tools tagTools tags.Tools
tagCache tagCache beforeAction func(ctx context.Context, reason string) error
tagCache tagCache
jobsMu sync.Mutex jobsMu sync.Mutex
jobs map[int64]*Job jobs map[int64]*Job
@@ -358,3 +359,19 @@ func sortedKeys(m map[string]fsops.Info) []string {
sort.Strings(keys) sort.Strings(keys)
return 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) 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: default:
return errors.New("multi-disc style must be prefix, folder or none") 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 { switch p.AutoOrganize {
case "": case "":
p.AutoOrganize = "off" 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 // renderName builds the organized path for a file from its tags. It returns
// the missing tag when a required one is empty. // 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] artist := f[tags.AlbumArtist]
if strings.TrimSpace(artist) == "" { if strings.TrimSpace(artist) == "" {
artist = f[tags.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) trackStr = fmt.Sprintf("%0*d", width, track)
if multi && multiDisc == "prefix" && disc > 0 { if multi && multiDisc == "prefix" && disc > 0 {
trackStr = fmt.Sprintf("%d-%s", disc, trackStr) trackStr = discStr(disc) + "-" + trackStr
} }
} }
year := "" year := ""
@@ -156,7 +167,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
} }
values := map[string]string{ values := map[string]string{
"artist": artist, "track_artist": f[tags.Artist], "album": f[tags.Album], "track": trackStr, "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 { if disc == 0 {
values["disc"] = "" values["disc"] = ""
@@ -188,7 +199,7 @@ func renderName(tpl, multiDisc, ext string, f map[string]string) (string, string
} }
if multi && multiDisc == "folder" && disc > 0 { if multi && multiDisc == "folder" && disc > 0 {
last := len(parts) - 1 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), "" 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" 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 { } 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" 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" item.Reason = "missing " + missing + " tag"
} else if to == rel { } else if to == rel {
plan.Conforming++ plan.Conforming++
@@ -616,6 +627,9 @@ func (e *Engine) autoOrganize(ctx context.Context, j *Job, l *libs) error {
if len(moves) == 0 { if len(moves) == 0 {
return nil 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)) e.log.Info("auto-organizing", "pair", l.pair.Name, "files", len(moves))
return e.applyOrganize(ctx, j, l, moves, "auto-organize") return e.applyOrganize(ctx, j, l, moves, "auto-organize")
} }
+15 -12
View File
@@ -20,23 +20,26 @@ func TestRenderName(t *testing.T) {
} }
cases := []struct { cases := []struct {
name, multi, ext string name, multi, ext string
digits int
fields map[string]string fields map[string]string
want, missing string want, missing string
}{ }{
{"basic", "prefix", ".mp3", f("artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1", "title", "Nightmare"), "Avenged Sevenfold/Nightmare/01 Nightmare.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", f("artist", "Avenged Sevenfold feat. X", "album_artist", "Avenged Sevenfold", "album", "A", "track", "3", "title", "T"), "Avenged Sevenfold/A/03 T.flac", ""}, {"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", f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""}, {"no track number", "prefix", ".mp3", 0, f("artist", "A", "album", "B", "title", "Song"), "A/B/Song.mp3", ""},
{"missing album", "prefix", ".mp3", f("artist", "A", "title", "Song"), "", "album"}, {"missing album", "prefix", ".mp3", 0, f("artist", "A", "title", "Song"), "", "album"},
{"missing title", "prefix", ".mp3", f("artist", "A", "album", "B", "track", "1"), "", "title"}, {"missing title", "prefix", ".mp3", 0, 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 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", f("artist", "A", "album", "B", "track", "4", "disc", "2", "title", "S"), "A/B/Disc 2/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", f("artist", "A", "album", "B", "track", "4", "disc", "1", "disc_total", "1", "title", "S"), "A/B/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", f("artist", "AC/DC", "album", "Who Made Who?", "track", "7", "title", "Title: Sub \"Live\"..."), "AC-DC/Who Made Who/07 Title - Sub 'Live'.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", f("artist", ".hidden", "album", "B", "title", "S"), "hidden/B/S.mp3", ""}, {"no hidden names", "prefix", ".mp3", 0, 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", ""}, {"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 { 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 { 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) 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 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) 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"` ApproxMatching bool `json:"approx_matching"`
ApproxThreshold int `json:"approx_threshold"` // percent ApproxThreshold int `json:"approx_threshold"` // percent
OrganizeTemplate string `json:"organize_template"` OrganizeTemplate string `json:"organize_template"`
OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none OrganizeMultiDisc string `json:"organize_multidisc"` // prefix, folder or none
AutoOrganize string `json:"auto_organize"` // off, slave, master or both 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"` LastScanAt *int64 `json:"last_scan_at"`
LastScanError string `json:"last_scan_error"` LastScanError string `json:"last_scan_error"`
LastScanWarnings []Warning `json:"last_scan_warnings"` 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, 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, 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` last_scan_warnings, created_at`
type scanner interface{ Scan(...any) error } type scanner interface{ Scan(...any) error }
@@ -171,7 +172,7 @@ func scanPair(r scanner) (Pair, error) {
var last sql.NullInt64 var last sql.NullInt64
err := r.Scan(&p.ID, &p.Name, &p.MasterRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, 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.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) &warns, &p.CreatedAt)
if err != nil { if err != nil {
return p, err return p, err
@@ -214,11 +215,11 @@ func (s *Store) SavePair(ctx context.Context, p *Pair) error {
if p.ID == 0 { if p.ID == 0 {
res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, master_root, slave_root, quarantine_dir, extensions, 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, auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, approx_matching, approx_threshold,
organize_template, organize_multidisc, auto_organize, created_at) organize_template, organize_multidisc, auto_organize, organize_disc_digits, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, 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 { if err != nil {
return err 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 = ?, _, 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 = ?, 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.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical,
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ApproxMatching, p.ApproxThreshold, 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 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_template TEXT NOT NULL DEFAULT '';
ALTER TABLE pairs ADD COLUMN organize_multidisc TEXT NOT NULL DEFAULT 'prefix'; ALTER TABLE pairs ADD COLUMN organize_multidisc TEXT NOT NULL DEFAULT 'prefix';
ALTER TABLE pairs ADD COLUMN auto_organize TEXT NOT NULL DEFAULT 'off';`, 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. // Open opens (and migrates) the database in dataDir.
func Open(dataDir string) (*Store, error) { func Open(dataDir string) (*Store, error) {
dsn := "file:" + filepath.Join(dataDir, "playlink.db") + dsn := "file:" + filepath.Join(dataDir, "playlink.db") +
+5
View File
@@ -79,6 +79,7 @@ export type Pair = {
organize_template: string; organize_template: string;
organize_multidisc: "prefix" | "folder" | "none"; organize_multidisc: "prefix" | "folder" | "none";
auto_organize: "off" | "slave" | "master" | "both"; auto_organize: "off" | "slave" | "master" | "both";
organize_disc_digits: number;
last_scan_at: number | null; last_scan_at: number | null;
last_scan_error: string; last_scan_error: string;
last_scan_warnings: ScanWarning[]; last_scan_warnings: ScanWarning[];
@@ -102,6 +103,7 @@ export type PairInput = Pick<
| "organize_template" | "organize_template"
| "organize_multidisc" | "organize_multidisc"
| "auto_organize" | "auto_organize"
| "organize_disc_digits"
>; >;
export type ProbeInfo = { export type ProbeInfo = {
@@ -240,3 +242,6 @@ export type TagFile = {
links: number; links: number;
size: number; size: number;
}; };
export type BackupConfig = { folder: string; interval_days: number; retention_days: number; before_actions: boolean };
export type Backup = { name: string; type: "manual" | "scheduled" | "action"; size: number; created: number };
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, Download, RotateCcw, Trash2, Upload } from "lucide-react";
import { api, type Backup, type BackupConfig } from "../api";
import { Alert, Badge, Button, Card, Field, Modal, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui";
import { useToast } from "../toast";
const typeTone = { manual: "violet", scheduled: "blue", action: "gray" } as const;
const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const;
export function BackupsCard() {
const qc = useQueryClient();
const toast = useToast();
const data = useQuery({ queryKey: ["backups"], queryFn: () => api.get<{ config: BackupConfig; backups: Backup[] }>("/backups") });
const [cfg, setCfg] = useState<BackupConfig | null>(null);
const [restore, setRestore] = useState<{ name?: string; file?: File } | null>(null);
const [del, setDel] = useState<Backup | null>(null);
const [restarting, setRestarting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (data.data) setCfg(data.data.config);
}, [data.data]);
const saved = data.data?.config;
const dirty = cfg && saved && JSON.stringify(cfg) !== JSON.stringify(saved);
const saveCfg = useMutation({
mutationFn: () => api.put<BackupConfig>("/backups/config", cfg),
onSuccess: () => {
toast("ok", "Backup settings saved");
qc.invalidateQueries({ queryKey: ["backups"] });
},
onError: (e) => toast("err", e.message),
});
const create = useMutation({
mutationFn: () => api.post<Backup>("/backups"),
onSuccess: (b) => {
toast("ok", `Backup created: ${b.name}`);
qc.invalidateQueries({ queryKey: ["backups"] });
},
onError: (e) => toast("err", e.message),
});
const remove = useMutation({
mutationFn: (b: Backup) => api.del(`/backups/${encodeURIComponent(b.name)}`),
onSuccess: () => {
setDel(null);
qc.invalidateQueries({ queryKey: ["backups"] });
},
onError: (e) => toast("err", e.message),
});
const doRestore = useMutation({
mutationFn: async () => {
if (restore?.name) return api.post(`/backups/${encodeURIComponent(restore.name)}/restore`);
const form = new FormData();
form.append("backup", restore!.file!);
const res = await fetch("/api/backups/restore-upload", {
method: "POST",
credentials: "same-origin",
headers: { "X-Playlink-Request": "1" },
body: form,
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
},
onSuccess: () => {
setRestore(null);
setRestarting(true);
},
onError: (e) => toast("err", e.message),
});
return (
<Card className="space-y-4 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 font-semibold">
<Archive className="size-4 text-accent" /> Backups
</h2>
<p className="text-sm text-muted">
Backups hold playlink's database: settings, library pairs, scan results and history. They do <b>not</b> contain music files; file
changes are undone from History.
</p>
</div>
<Button size="sm" variant="primary" onClick={() => create.mutate()} loading={create.isPending}>
Back up now
</Button>
</div>
{!cfg && <Spinner />}
{cfg && (
<>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Backup folder" hint="Inside the container, e.g. /config/backups">
<input className={inputCls + " mono text-xs"} value={cfg.folder} onChange={(e) => setCfg({ ...cfg, folder: e.target.value })} />
</Field>
<Field label="Scheduled backups">
<select className={inputCls} value={cfg.interval_days} onChange={(e) => setCfg({ ...cfg, interval_days: Number(e.target.value) })}>
<option value={0}>Off</option>
<option value={1}>Every day</option>
<option value={3}>Every 3 days</option>
<option value={7}>Every 7 days</option>
<option value={14}>Every 14 days</option>
</select>
</Field>
<Field label="Retention (days)" hint="For scheduled backups. Manual ones are kept until deleted.">
<input className={inputCls} type="number" min={1} value={cfg.retention_days} onChange={(e) => setCfg({ ...cfg, retention_days: Number(e.target.value) })} />
</Field>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={cfg.before_actions}
onChange={(v) => setCfg({ ...cfg, before_actions: v })}
label={<span className="font-medium">Back up before every action in live mode</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
One backup per action you start (a link, a bulk action, a tag edit, renaming folders, an undo…) and before scans change files, not one
per file. If the backup fails, the action doesn't run. The newest 10 are kept.
</p>
</div>
{dirty && (
<div className="flex justify-end">
<Button size="sm" variant="primary" onClick={() => saveCfg.mutate()} loading={saveCfg.isPending}>
Save backup settings
</Button>
</div>
)}
</>
)}
<div className="overflow-hidden rounded-lg border border-line">
{data.data?.backups.length === 0 && <div className="p-4 text-center text-sm text-muted">No backups yet.</div>}
{data.data?.backups.map((b) => (
<div key={b.name} className="flex flex-wrap items-center gap-3 border-b border-line px-3 py-2 last:border-0">
<Badge tone={typeTone[b.type]}>{typeLabel[b.type]}</Badge>
<div className="min-w-0 flex-1">
<div className="truncate text-sm mono" title={b.name}>
{b.name}
</div>
<div className="text-xs text-muted">
{new Date(b.created * 1000).toLocaleString()} · {formatAgo(b.created)} · {formatBytes(b.size)}
</div>
</div>
<a
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-gray-300 hover:bg-panel-2"
href={`/api/backups/${encodeURIComponent(b.name)}/download`}
download
>
<Download className="size-3.5" /> Download
</a>
<Button size="sm" onClick={() => setRestore({ name: b.name })}>
<RotateCcw className="size-3.5" /> Restore
</Button>
<Button size="sm" variant="ghost" onClick={() => setDel(b)} title="Delete backup">
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<input
ref={fileRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setRestore({ file: f });
e.target.value = "";
}}
/>
<Button size="sm" variant="ghost" onClick={() => fileRef.current?.click()}>
<Upload className="size-3.5" /> Restore from a file…
</Button>
a backup .zip downloaded earlier
</div>
<Modal open={!!restore} onClose={() => setRestore(null)} title="Restore this backup?">
<div className="space-y-3 text-sm text-gray-300">
<p>
playlink's database is replaced with <code className="mono break-all">{restore?.name ?? restore?.file?.name}</code>: settings, library
pairs, scan results and history go back to how they were then.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-muted">
<li>The backup is checked first; a damaged backup or one from a newer playlink version is refused.</li>
<li>The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.</li>
<li>Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.</li>
<li>
playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
</li>
</ul>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setRestore(null)}>
Cancel
</Button>
<Button variant="warn" loading={doRestore.isPending} onClick={() => doRestore.mutate()}>
Restore and restart
</Button>
</div>
</Modal>
<Modal open={!!del} onClose={() => setDel(null)} title="Delete backup?">
<p className="text-sm text-gray-300">
<code className="mono break-all">{del?.name}</code> is deleted permanently.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDel(null)}>
Cancel
</Button>
<Button variant="danger" loading={remove.isPending} onClick={() => del && remove.mutate(del)}>
Delete
</Button>
</div>
</Modal>
{restarting && <Restarting />}
</Card>
);
}
/** Waits for playlink to come back after a restore, then reloads the page. */
function Restarting() {
const [slow, setSlow] = useState(false);
useEffect(() => {
const started = Date.now();
let wentDown = false;
const t = setInterval(async () => {
try {
const res = await fetch("/healthz", { cache: "no-store" });
if (res.ok && (wentDown || Date.now() - started > 5000)) window.location.reload();
if (!res.ok) wentDown = true;
} catch {
wentDown = true;
}
if (Date.now() - started > 60000) setSlow(true);
}, 1000);
return () => clearInterval(t);
}, []);
return (
<Modal open onClose={() => {}} title="Restoring backup…">
<div className="flex items-center gap-3 text-sm text-gray-300">
<Spinner /> playlink is restarting with the restored database. This page reloads when it is back.
</div>
{slow && (
<div className="mt-3">
<Alert tone="amber">
playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when
it starts.
</Alert>
</div>
)}
</Modal>
);
}
+21 -10
View File
@@ -29,12 +29,13 @@ const SAMPLE: Record<string, string> = {
}; };
/** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */ /** Renders the pattern for a sample song; disc 2 shows the multi-disc style. */
function example(tpl: string, multi?: Pair["organize_multidisc"]) { function example(tpl: string, multi?: Pair["organize_multidisc"], digits = 2) {
const d = digits === 1 ? "2" : "02";
const values: Record<string, string> = multi const values: Record<string, string> = multi
? { ...SAMPLE, track: multi === "prefix" ? "2-04" : "04", title: "Victim", disc: "2" } ? { ...SAMPLE, track: multi === "prefix" ? `${d}-04` : "04", title: "Victim", disc: d }
: SAMPLE; : { ...SAMPLE, disc: digits === 1 ? "1" : "01" };
let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`)); let parts = tpl.split("/").map((part) => part.replace(/\{([a-z_]+)\}/g, (_, k: string) => values[k] ?? `{${k}}`));
if (multi === "folder") parts = [...parts.slice(0, -1), "Disc 2", parts[parts.length - 1]]; if (multi === "folder") parts = [...parts.slice(0, -1), `Disc ${d}`, parts[parts.length - 1]];
return parts.join("/") + ".flac"; return parts.join("/") + ".flac";
} }
@@ -55,12 +56,15 @@ function NamingSettings({ pair }: { pair: Pair }) {
const [tpl, setTpl] = useState(pair.organize_template); const [tpl, setTpl] = useState(pair.organize_template);
const [multi, setMulti] = useState(pair.organize_multidisc); const [multi, setMulti] = useState(pair.organize_multidisc);
const [auto, setAuto] = useState(pair.auto_organize); const [auto, setAuto] = useState(pair.auto_organize);
const [digits, setDigits] = useState(pair.organize_disc_digits || 2);
useEffect(() => { useEffect(() => {
setTpl(pair.organize_template); setTpl(pair.organize_template);
setMulti(pair.organize_multidisc); setMulti(pair.organize_multidisc);
setAuto(pair.auto_organize); setAuto(pair.auto_organize);
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize]); setDigits(pair.organize_disc_digits || 2);
const dirty = tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize; }, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize, pair.organize_disc_digits]);
const dirty =
tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize || digits !== (pair.organize_disc_digits || 2);
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () =>
@@ -79,6 +83,7 @@ function NamingSettings({ pair }: { pair: Pair }) {
organize_template: tpl, organize_template: tpl,
organize_multidisc: multi, organize_multidisc: multi,
auto_organize: auto, auto_organize: auto,
organize_disc_digits: digits,
}), }),
onSuccess: () => { onSuccess: () => {
toast("ok", "Naming settings saved. Preview again to see the effect."); toast("ok", "Naming settings saved. Preview again to see the effect.");
@@ -122,21 +127,27 @@ function NamingSettings({ pair }: { pair: Pair }) {
</div> </div>
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs"> <div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
<div className="text-muted">Example</div> <div className="text-muted">Example</div>
<div className="mono break-all text-gray-100">{example(tpl)}</div> <div className="mono break-all text-gray-100">{example(tpl, undefined, digits)}</div>
{multi !== "none" && ( {multi !== "none" && (
<div className="mono break-all text-gray-400"> <div className="mono break-all text-gray-400">
{example(tpl, multi)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span> {example(tpl, multi, digits)} <span className="font-sans text-muted">(disc 2 of a multi-disc album)</span>
</div> </div>
)} )}
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name."> <Field label="Multi-disc albums" hint="Keeps disc 1 and disc 2 tracks from getting the same name.">
<select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}> <select className={inputCls} value={multi} onChange={(e) => setMulti(e.target.value as Pair["organize_multidisc"])}>
<option value="prefix">Disc number before the track (2-04 Title)</option> <option value="prefix">Disc number before the track ({digits === 1 ? "2" : "02"}-04 Title)</option>
<option value="folder">A folder per disc (Disc 2/04 Title)</option> <option value="folder">A folder per disc (Disc {digits === 1 ? "2" : "02"}/04 Title)</option>
<option value="none">Nothing (clashing names are skipped)</option> <option value="none">Nothing (clashing names are skipped)</option>
</select> </select>
</Field> </Field>
<Field label="Disc numbers" hint="Also used for the {disc} token.">
<select className={inputCls} value={digits} onChange={(e) => setDigits(Number(e.target.value))}>
<option value={2}>Two digits: Disc 01, 01-04</option>
<option value={1}>As is: Disc 1, 1-04</option>
</select>
</Field>
<Field label="Organize automatically" hint="Renames run at the start of every scan."> <Field label="Organize automatically" hint="Renames run at the start of every scan.">
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}> <select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
<option value="off">Off: nothing is renamed</option> <option value="off">Off: nothing is renamed</option>
+2
View File
@@ -28,6 +28,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
organize_template: "", organize_template: "",
organize_multidisc: "prefix", organize_multidisc: "prefix",
auto_organize: "off", auto_organize: "off",
organize_disc_digits: 2,
}; };
const [f, setF] = useState<PairInput>(pair ?? blank); const [f, setF] = useState<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", ")); const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
@@ -59,6 +60,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
organize_template: f.organize_template, organize_template: f.organize_template,
organize_multidisc: f.organize_multidisc, organize_multidisc: f.organize_multidisc,
auto_organize: f.auto_organize, auto_organize: f.auto_organize,
organize_disc_digits: f.organize_disc_digits,
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean), extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
}); });
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => { const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
+4 -1
View File
@@ -4,6 +4,7 @@ import { api } from "../api";
import { useInfo } from "../App"; import { useInfo } from "../App";
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui"; import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { BackupsCard } from "./Backups";
export function SettingsPage() { export function SettingsPage() {
const info = useInfo(); const info = useInfo();
@@ -36,7 +37,7 @@ export function SettingsPage() {
}); });
return ( return (
<div className="max-w-2xl space-y-5"> <div className="max-w-3xl space-y-5">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1> <h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<Card className="space-y-3 p-5"> <Card className="space-y-3 p-5">
@@ -78,6 +79,8 @@ export function SettingsPage() {
</p> </p>
</Card> </Card>
<BackupsCard />
<Card className="space-y-2 p-5"> <Card className="space-y-2 p-5">
<h2 className="font-semibold">Environment</h2> <h2 className="font-semibold">Environment</h2>
<div className="text-sm"> <div className="text-sm">