Add database backups and two-digit disc numbers
Disc numbers: the organizer writes two-digit disc numbers by default
(Disc 01, 01-04 Title, {disc}), with an option for the plain style
(Disc 2, 2-04). Migration 8 adds the per-pair setting.
Backups (Settings -> Backups), similar to Sonarr:
- A backup is a zip with a consistent snapshot of the database (VACUUM
INTO, safe while running) and backup.json. Configurable folder,
schedule (off/1/3/7/14 days) and retention for scheduled backups;
manual backups are kept until deleted. List, download, delete.
- Restore from a listed backup or an uploaded zip: validated first
(integrity check, playlink tables, not a newer schema), staged next to
the live database, then the server restarts; at startup the staged
database is swapped in and the previous one kept as
playlink.db.pre-restore-<time>. Refused while a job runs. Backup names
are validated, so downloads/deletes cannot reach other files.
- Optional backup before every action in live mode: once per action the
user starts (link, promote, bulk, build/remove, tag edit, folder
renames, undo, purge) and before scans auto-link or auto-organize,
never per file and never in dry run. If the backup fails the action
does not run. The newest 10 action backups are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96ea856dfd
commit
49fc32096a
18 files changed
+1235
-47
No files matched your search
@@ -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}` →
|
||||
`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
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
|
||||
+17
-3
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/api"
|
||||
"github.com/cdnutter/playlink/internal/backup"
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
"github.com/cdnutter/playlink/internal/probe"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
@@ -82,6 +83,11 @@ func main() {
|
||||
log.Error("create data dir", "err", err)
|
||||
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)
|
||||
if err != nil {
|
||||
log.Error("open database", "err", err)
|
||||
@@ -99,12 +105,19 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
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)
|
||||
go scheduler(ctx, st, eng, log)
|
||||
go scheduler(ctx, st, eng, backups, log)
|
||||
|
||||
srv := &http.Server{
|
||||
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,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
@@ -125,12 +138,13 @@ func main() {
|
||||
}
|
||||
|
||||
// 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)
|
||||
defer tick.Stop()
|
||||
lastMaint := time.Time{}
|
||||
for {
|
||||
if time.Since(lastMaint) >= time.Hour {
|
||||
backups.Due(ctx)
|
||||
eng.Maintenance(ctx)
|
||||
lastMaint = time.Now()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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") +
|
||||
|
||||
@@ -79,6 +79,7 @@ export type Pair = {
|
||||
organize_template: string;
|
||||
organize_multidisc: "prefix" | "folder" | "none";
|
||||
auto_organize: "off" | "slave" | "master" | "both";
|
||||
organize_disc_digits: number;
|
||||
last_scan_at: number | null;
|
||||
last_scan_error: string;
|
||||
last_scan_warnings: ScanWarning[];
|
||||
@@ -102,6 +103,7 @@ export type PairInput = Pick<
|
||||
| "organize_template"
|
||||
| "organize_multidisc"
|
||||
| "auto_organize"
|
||||
| "organize_disc_digits"
|
||||
>;
|
||||
|
||||
export type ProbeInfo = {
|
||||
@@ -240,3 +242,6 @@ export type TagFile = {
|
||||
links: 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 };
|
||||
@@ -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
@@ -29,12 +29,13 @@ const SAMPLE: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** 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
|
||||
? { ...SAMPLE, track: multi === "prefix" ? "2-04" : "04", title: "Victim", disc: "2" }
|
||||
: SAMPLE;
|
||||
? { ...SAMPLE, track: multi === "prefix" ? `${d}-04` : "04", title: "Victim", disc: d }
|
||||
: { ...SAMPLE, disc: digits === 1 ? "1" : "01" };
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -55,12 +56,15 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
const [tpl, setTpl] = useState(pair.organize_template);
|
||||
const [multi, setMulti] = useState(pair.organize_multidisc);
|
||||
const [auto, setAuto] = useState(pair.auto_organize);
|
||||
const [digits, setDigits] = useState(pair.organize_disc_digits || 2);
|
||||
useEffect(() => {
|
||||
setTpl(pair.organize_template);
|
||||
setMulti(pair.organize_multidisc);
|
||||
setAuto(pair.auto_organize);
|
||||
}, [pair.id, pair.organize_template, pair.organize_multidisc, pair.auto_organize]);
|
||||
const dirty = tpl !== pair.organize_template || multi !== pair.organize_multidisc || auto !== pair.auto_organize;
|
||||
setDigits(pair.organize_disc_digits || 2);
|
||||
}, [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({
|
||||
mutationFn: () =>
|
||||
@@ -79,6 +83,7 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
organize_template: tpl,
|
||||
organize_multidisc: multi,
|
||||
auto_organize: auto,
|
||||
organize_disc_digits: digits,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast("ok", "Naming settings saved. Preview again to see the effect.");
|
||||
@@ -122,21 +127,27 @@ function NamingSettings({ pair }: { pair: Pair }) {
|
||||
</div>
|
||||
<div className="rounded-lg border border-line bg-bg/60 p-3 text-xs">
|
||||
<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" && (
|
||||
<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 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.">
|
||||
<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="folder">A folder per disc (Disc 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 {digits === 1 ? "2" : "02"}/04 Title)</option>
|
||||
<option value="none">Nothing (clashing names are skipped)</option>
|
||||
</select>
|
||||
</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.">
|
||||
<select className={inputCls} value={auto} onChange={(e) => setAuto(e.target.value as Pair["auto_organize"])}>
|
||||
<option value="off">Off: nothing is renamed</option>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean;
|
||||
organize_template: "",
|
||||
organize_multidisc: "prefix",
|
||||
auto_organize: "off",
|
||||
organize_disc_digits: 2,
|
||||
};
|
||||
const [f, setF] = useState<PairInput>(pair ?? blank);
|
||||
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_multidisc: f.organize_multidisc,
|
||||
auto_organize: f.auto_organize,
|
||||
organize_disc_digits: f.organize_disc_digits,
|
||||
extensions: exts.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
});
|
||||
const set = <K extends keyof PairInput>(k: K, v: PairInput[K]) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
||||
import { useInfo } from "../App";
|
||||
import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { BackupsCard } from "./Backups";
|
||||
|
||||
export function SettingsPage() {
|
||||
const info = useInfo();
|
||||
@@ -36,7 +37,7 @@ export function SettingsPage() {
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<Card className="space-y-3 p-5">
|
||||
@@ -78,6 +79,8 @@ export function SettingsPage() {
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<BackupsCard />
|
||||
|
||||
<Card className="space-y-2 p-5">
|
||||
<h2 className="font-semibold">Environment</h2>
|
||||
<div className="text-sm">
|
||||
|
||||
Reference in new issue
Block a user