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
+61
-4
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/backup"
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
@@ -28,6 +29,8 @@ import (
|
||||
type Config struct {
|
||||
SecureCookies bool
|
||||
Version string
|
||||
Backups *backup.Manager
|
||||
Restart func() // stops the server so the process restarts (after a restore)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -54,7 +57,11 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Use(s.csrf)
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
limit := int64(1 << 20)
|
||||
if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") {
|
||||
limit = 20 << 30 // backup zips can be large
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
@@ -72,6 +79,9 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Get("/settings", s.handleGetSettings)
|
||||
r.Put("/settings", s.handlePutSettings)
|
||||
r.Get("/browse", s.handleBrowse)
|
||||
if s.cfg.Backups != nil {
|
||||
s.backupRoutes(r)
|
||||
}
|
||||
|
||||
r.Get("/pairs", s.handleListPairs)
|
||||
r.Post("/pairs", s.handleSavePair)
|
||||
@@ -340,6 +350,7 @@ type pairInput struct {
|
||||
OrganizeTemplate string `json:"organize_template"`
|
||||
OrganizeMultiDisc string `json:"organize_multidisc"`
|
||||
AutoOrganize string `json:"auto_organize"`
|
||||
OrganizeDiscDigits int `json:"organize_disc_digits"`
|
||||
}
|
||||
|
||||
func (in pairInput) apply(p *store.Pair) {
|
||||
@@ -348,6 +359,7 @@ func (in pairInput) apply(p *store.Pair) {
|
||||
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
|
||||
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
|
||||
p.OrganizeTemplate, p.OrganizeMultiDisc, p.AutoOrganize = in.OrganizeTemplate, in.OrganizeMultiDisc, in.AutoOrganize
|
||||
p.OrganizeDiscDigits = in.OrganizeDiscDigits
|
||||
}
|
||||
|
||||
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -469,6 +481,9 @@ func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleLinkIdentical(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
if !s.preAction(w, r, id, "link identical") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartLinkIdentical(id)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -492,14 +507,14 @@ type pathsBody struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleBuild(w http.ResponseWriter, r *http.Request) {
|
||||
s.pathsJob(w, r, s.eng.StartBuild)
|
||||
s.pathsJob(w, r, "build", s.eng.StartBuild)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemove(w http.ResponseWriter, r *http.Request) {
|
||||
s.pathsJob(w, r, s.eng.StartRemove)
|
||||
s.pathsJob(w, r, "remove from slave", s.eng.StartRemove)
|
||||
}
|
||||
|
||||
func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int64, []string) (*engine.Job, error)) {
|
||||
func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, reason string, start func(int64, []string) (*engine.Job, error)) {
|
||||
id, _ := idParam(r)
|
||||
var body pathsBody
|
||||
if !readJSON(w, r, &body) {
|
||||
@@ -515,6 +530,9 @@ func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, start func(int
|
||||
return
|
||||
}
|
||||
}
|
||||
if !s.preAction(w, r, id, reason) {
|
||||
return
|
||||
}
|
||||
j, err := start(id, body.Paths)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -534,6 +552,9 @@ func (s *Server) handleLink(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, 0, "link") {
|
||||
return
|
||||
}
|
||||
op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade, KeepFolder: body.KeepFolder})
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -550,6 +571,9 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.Confirm && !s.preAction(w, r, 0, "promote") {
|
||||
return
|
||||
}
|
||||
op, err := s.eng.Promote(r.Context(), id, body.Confirm)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -590,6 +614,9 @@ func (s *Server) handleOps(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
if !s.preAction(w, r, 0, "undo") {
|
||||
return
|
||||
}
|
||||
if err := s.eng.Undo(r.Context(), id); err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
@@ -599,6 +626,9 @@ func (s *Server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
if !s.preAction(w, r, 0, "purge quarantine") {
|
||||
return
|
||||
}
|
||||
if err := s.eng.Purge(r.Context(), id); err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
@@ -653,6 +683,9 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"changed": n})
|
||||
default:
|
||||
if !s.preAction(w, r, id, "bulk "+b.Action) {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartBulk(id, b.IDs, b.BulkOptions)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -690,6 +723,9 @@ func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !s.preAction(w, r, id, "rename folders") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartRenames(id, body.Only)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -735,6 +771,9 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, id, "edit tags") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartTagEdit(id, body)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
@@ -743,3 +782,21 @@ func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
|
||||
s.log.Info("tag edit started", "pair", id, "side", body.Side, "files", len(body.Paths), "fields", len(body.Set), "separate", body.Separate)
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// preAction runs the optional database backup before an action that changes
|
||||
// files (live mode only). It refuses up front when the pair is busy, so no
|
||||
// backup is made for an action that could not start.
|
||||
func (s *Server) preAction(w http.ResponseWriter, r *http.Request, pairID int64, reason string) bool {
|
||||
if pairID != 0 {
|
||||
if j := s.eng.Job(pairID); j != nil && j.State == "running" {
|
||||
writeErr(w, 409, engine.ErrBusy)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := s.eng.BeforeAction(r.Context(), reason); err != nil {
|
||||
s.log.Warn("pre-action backup failed", "action", reason, "err", err)
|
||||
writeErr(w, 500, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user