Add database backups and two-digit disc numbers
Disc numbers: the organizer writes two-digit disc numbers by default
(Disc 01, 01-04 Title, {disc}), with an option for the plain style
(Disc 2, 2-04). Migration 8 adds the per-pair setting.
Backups (Settings -> Backups), similar to Sonarr:
- A backup is a zip with a consistent snapshot of the database (VACUUM
INTO, safe while running) and backup.json. Configurable folder,
schedule (off/1/3/7/14 days) and retention for scheduled backups;
manual backups are kept until deleted. List, download, delete.
- Restore from a listed backup or an uploaded zip: validated first
(integrity check, playlink tables, not a newer schema), staged next to
the live database, then the server restarts; at startup the staged
database is swapped in and the previous one kept as
playlink.db.pre-restore-<time>. Refused while a job runs. Backup names
are validated, so downloads/deletes cannot reach other files.
- Optional backup before every action in live mode: once per action the
user starts (link, promote, bulk, build/remove, tag edit, folder
renames, undo, purge) and before scans auto-link or auto-organize,
never per file and never in dry run. If the backup fails the action
does not run. The newest 10 action backups are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
96ea856dfd
commit
49fc32096a
18 files changed
+1235
-47
No files matched your search
@@ -0,0 +1,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()
|
||||
}()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user