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
+1231
-43
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}` →
|
**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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
"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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ type Engine struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
tagTools tags.Tools
|
tagTools tags.Tools
|
||||||
|
beforeAction func(ctx context.Context, reason string) error
|
||||||
tagCache tagCache
|
tagCache tagCache
|
||||||
|
|
||||||
jobsMu sync.Mutex
|
jobsMu sync.Mutex
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ type Pair struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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") +
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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. */
|
/** 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>
|
||||||
|
|||||||
@@ -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,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">
|
||||||
|
|||||||
Reference in new issue
Block a user