A new Duplicates filter in Review finds songs filed more than once in the slave library, such as a single next to its album version, and recommends which copy to keep. Removed copies go to the quarantine and can be restored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
855 lines
23 KiB
Go
855 lines
23 KiB
Go
// Package api exposes the playlink HTTP API and serves the web UI.
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
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 {
|
|
cfg Config
|
|
st *store.Store
|
|
eng *engine.Engine
|
|
log *slog.Logger
|
|
web fs.FS
|
|
loginLimit *limiter
|
|
}
|
|
|
|
func New(cfg Config, st *store.Store, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
|
return &Server{cfg: cfg, st: st, eng: eng, log: log, web: web, loginLimit: newLimiter(10, 5*time.Minute)}
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(securityHeaders)
|
|
|
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
|
|
|
|
r.Route("/api", func(r chi.Router) {
|
|
r.Use(s.csrf)
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
})
|
|
})
|
|
r.Get("/auth/state", s.handleAuthState)
|
|
r.Post("/auth/setup", s.handleSetup)
|
|
r.Post("/auth/login", s.handleLogin)
|
|
r.Post("/auth/logout", s.handleLogout)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireAuth)
|
|
r.Post("/auth/password", s.handleChangePassword)
|
|
|
|
r.Get("/info", s.handleInfo)
|
|
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)
|
|
r.Post("/pairs/validate", s.handleValidatePair)
|
|
r.Route("/pairs/{id}", func(r chi.Router) {
|
|
r.Get("/", s.handleGetPair)
|
|
r.Put("/", s.handleSavePair)
|
|
r.Delete("/", s.handleDeletePair)
|
|
r.Post("/scan", s.handleScan)
|
|
r.Get("/job", s.handleJob)
|
|
r.Post("/job/cancel", s.handleCancelJob)
|
|
r.Get("/matches", s.handleMatches)
|
|
r.Get("/export.csv", s.handleExport)
|
|
r.Get("/groups", s.handleGroups)
|
|
r.Post("/bulk/preview", s.handleBulkPreview)
|
|
r.Post("/bulk", s.handleBulk)
|
|
r.Get("/renames", s.handleRenamePreview)
|
|
r.Post("/renames", s.handleRenames)
|
|
r.Post("/link-identical", s.handleLinkIdentical)
|
|
r.Get("/tree", s.handleTree)
|
|
r.Get("/listen", s.handleListen)
|
|
r.Get("/empty-folders", s.handleEmptyFolders)
|
|
r.Post("/empty-folders/scan", s.handleEmptyFolderScan)
|
|
r.Post("/empty-folders/remove", s.handleRemoveEmptyFolders)
|
|
r.Get("/duplicates", s.handleDupes)
|
|
r.Post("/duplicates/scan", s.handleDupeScan)
|
|
r.Post("/duplicates/remove", s.handleRemoveDupes)
|
|
r.Post("/build", s.handleBuild)
|
|
r.Post("/remove", s.handleRemove)
|
|
})
|
|
|
|
r.Post("/matches/{id}/link", s.handleLink)
|
|
r.Post("/matches/{id}/promote", s.handlePromote)
|
|
r.Post("/matches/{id}/ignore", s.handleIgnore)
|
|
|
|
r.Get("/operations", s.handleOps)
|
|
r.Post("/operations/{id}/undo", s.handleUndo)
|
|
r.Post("/operations/{id}/purge", s.handlePurge)
|
|
})
|
|
})
|
|
|
|
r.NotFound(s.serveUI)
|
|
return r
|
|
}
|
|
|
|
func securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "same-origin")
|
|
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "+
|
|
"script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// serveUI serves the embedded single-page app, falling back to index.html.
|
|
func (s *Server) serveUI(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeErr(w, 404, errors.New("not found"))
|
|
return
|
|
}
|
|
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
|
if p == "" {
|
|
p = "index.html"
|
|
}
|
|
if _, err := fs.Stat(s.web, p); err != nil {
|
|
p = "index.html"
|
|
}
|
|
if strings.HasPrefix(p, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
http.ServeFileFS(w, r, s.web, p)
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, code int, err error) {
|
|
writeJSON(w, code, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil && !errors.Is(err, io.EOF) {
|
|
writeErr(w, 400, errors.New("invalid request body: "+err.Error()))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func idParam(r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
return id, err == nil && id > 0
|
|
}
|
|
|
|
// opErr maps engine errors onto HTTP status codes.
|
|
func opErr(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
writeErr(w, 404, err)
|
|
case errors.Is(err, engine.ErrBusy), errors.Is(err, engine.ErrDowngrade), errors.Is(err, engine.ErrConfirm),
|
|
errors.Is(err, fsops.ErrChanged), errors.Is(err, fsops.ErrExists):
|
|
writeErr(w, 409, err)
|
|
case errors.Is(err, fsops.ErrOutsideRoot), errors.Is(err, fsops.ErrNotRegular), errors.Is(err, fsops.ErrCrossDevice),
|
|
errors.Is(err, engine.ErrInvalidBulk):
|
|
writeErr(w, 400, err)
|
|
default:
|
|
writeErr(w, 500, err)
|
|
}
|
|
}
|
|
|
|
// ---- info & settings ----
|
|
|
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{
|
|
"version": s.cfg.Version,
|
|
"allowed_roots": s.eng.AllowedRoots(),
|
|
"default_extensions": engine.DefaultExtensions,
|
|
"dry_run": s.st.DryRun(r.Context()),
|
|
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context()),
|
|
"plex_ignore": s.st.Setting(r.Context(), "plex_ignore", "0") == "1"})
|
|
}
|
|
|
|
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
DryRun *bool `json:"dry_run"`
|
|
PlexIgnore *bool `json:"plex_ignore"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
if body.DryRun != nil {
|
|
v := "0"
|
|
if *body.DryRun {
|
|
v = "1"
|
|
}
|
|
if err := s.st.SetSetting(r.Context(), "dry_run", v); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
s.log.Info("dry run changed", "dry_run", *body.DryRun)
|
|
}
|
|
if body.PlexIgnore != nil {
|
|
v := "0"
|
|
if *body.PlexIgnore {
|
|
v = "1"
|
|
}
|
|
if err := s.st.SetSetting(r.Context(), "plex_ignore", v); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
results := s.eng.ApplyPlexIgnore(r.Context(), *body.PlexIgnore)
|
|
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context()), "plex_ignore": *body.PlexIgnore, "results": results})
|
|
return
|
|
}
|
|
s.handleGetSettings(w, r)
|
|
}
|
|
|
|
// handleBrowse lists subdirectories for the folder picker, confined to the
|
|
// allowed roots.
|
|
func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
|
|
p := r.URL.Query().Get("path")
|
|
roots := s.eng.AllowedRoots()
|
|
if p == "" {
|
|
writeJSON(w, 200, map[string]any{"path": "", "parent": "", "dirs": roots})
|
|
return
|
|
}
|
|
p = filepath.Clean(p)
|
|
real, err := filepath.EvalSymlinks(p)
|
|
if err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
ok := false
|
|
for _, root := range roots {
|
|
if fsops.Within(root, real) {
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
writeErr(w, 403, fsops.ErrOutsideRoot)
|
|
return
|
|
}
|
|
ents, err := os.ReadDir(real)
|
|
if err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
dirs := []string{}
|
|
for _, en := range ents {
|
|
if en.IsDir() && !strings.HasPrefix(en.Name(), ".") {
|
|
dirs = append(dirs, filepath.Join(real, en.Name()))
|
|
}
|
|
}
|
|
sort.Strings(dirs)
|
|
parent := filepath.Dir(real)
|
|
for _, root := range roots {
|
|
if real == root {
|
|
parent = ""
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"path": real, "parent": parent, "dirs": dirs})
|
|
}
|
|
|
|
// ---- pairs ----
|
|
|
|
type pairView struct {
|
|
store.Pair
|
|
Summary store.Summary `json:"summary"`
|
|
Job *engine.Job `json:"job"`
|
|
}
|
|
|
|
func (s *Server) view(r *http.Request, p store.Pair) pairView {
|
|
sum, _ := s.st.Summary(r.Context(), p.ID)
|
|
return pairView{Pair: p, Summary: sum, Job: s.eng.Job(p.ID)}
|
|
}
|
|
|
|
func (s *Server) handleListPairs(w http.ResponseWriter, r *http.Request) {
|
|
pairs, err := s.st.Pairs(r.Context())
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
out := make([]pairView, 0, len(pairs))
|
|
for _, p := range pairs {
|
|
out = append(out, s.view(r, p))
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) handleGetPair(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
p, err := s.st.Pair(r.Context(), id)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, s.view(r, p))
|
|
}
|
|
|
|
type pairInput struct {
|
|
Name string `json:"name"`
|
|
MasterRoot string `json:"master_root"`
|
|
SlaveRoot string `json:"slave_root"`
|
|
QuarantineDir string `json:"quarantine_dir"`
|
|
Extensions []string `json:"extensions"`
|
|
AutoLinkIdentical bool `json:"auto_link_identical"`
|
|
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
|
RetentionDays int `json:"retention_days"`
|
|
SettleMinutes int `json:"settle_minutes"`
|
|
ApproxMatching bool `json:"approx_matching"`
|
|
ApproxThreshold int `json:"approx_threshold"`
|
|
}
|
|
|
|
func (in pairInput) apply(p *store.Pair) {
|
|
p.Name, p.MasterRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.MasterRoot, in.SlaveRoot, in.QuarantineDir
|
|
p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical
|
|
p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes
|
|
p.ApproxMatching, p.ApproxThreshold = in.ApproxMatching, in.ApproxThreshold
|
|
}
|
|
|
|
func (s *Server) handleValidatePair(w http.ResponseWriter, r *http.Request) {
|
|
var in pairInput
|
|
if !readJSON(w, r, &in) {
|
|
return
|
|
}
|
|
var p store.Pair
|
|
in.apply(&p)
|
|
warns, err := s.eng.NormalizePair(&p)
|
|
if err != nil {
|
|
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "warnings": []store.Warning{}})
|
|
return
|
|
}
|
|
if warns == nil {
|
|
warns = []store.Warning{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true, "warnings": warns, "pair": p})
|
|
}
|
|
|
|
func (s *Server) handleSavePair(w http.ResponseWriter, r *http.Request) {
|
|
var in pairInput
|
|
if !readJSON(w, r, &in) {
|
|
return
|
|
}
|
|
var p store.Pair
|
|
var old store.Pair
|
|
if id, ok := idParam(r); ok {
|
|
var err error
|
|
if old, err = s.st.Pair(r.Context(), id); err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
p = old
|
|
if j := s.eng.Job(id); j != nil && j.State == "running" {
|
|
writeErr(w, 409, engine.ErrBusy)
|
|
return
|
|
}
|
|
}
|
|
in.apply(&p)
|
|
warns, err := s.eng.NormalizePair(&p)
|
|
if err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
if err := s.st.SavePair(r.Context(), &p); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
if old.ID != 0 && (old.MasterRoot != p.MasterRoot || old.SlaveRoot != p.SlaveRoot) {
|
|
_ = s.st.ClearMatches(r.Context(), p.ID) // results no longer apply
|
|
}
|
|
p, _ = s.st.Pair(r.Context(), p.ID)
|
|
if warns == nil {
|
|
warns = []store.Warning{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"pair": s.view(r, p), "warnings": warns})
|
|
}
|
|
|
|
func (s *Server) handleDeletePair(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
if j := s.eng.Job(id); j != nil && j.State == "running" {
|
|
writeErr(w, 409, engine.ErrBusy)
|
|
return
|
|
}
|
|
// Only configuration and history are removed; files are never touched.
|
|
if err := s.st.DeletePair(r.Context(), id); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
if _, err := s.st.Pair(r.Context(), id); err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
j, err := s.eng.StartScan(id, "manual")
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 202, j)
|
|
}
|
|
|
|
func (s *Server) handleJob(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
writeJSON(w, 200, s.eng.Job(id))
|
|
}
|
|
|
|
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
writeJSON(w, 200, map[string]any{"cancelled": s.eng.CancelJob(id)})
|
|
}
|
|
|
|
func (s *Server) handleMatches(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
q := r.URL.Query()
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
|
var ids []int64
|
|
for _, part := range strings.Split(q.Get("ids"), ",") {
|
|
if n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil && n > 0 {
|
|
ids = append(ids, n)
|
|
}
|
|
}
|
|
ms, total, err := s.st.Matches(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q"), IDs: ids, Limit: limit, Offset: offset})
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
for i := range ms {
|
|
ms[i].LinkTarget = engine.LinkTarget(ms[i].RelSlave, ms[i].RelMaster, false)
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": ms, "total": total})
|
|
}
|
|
|
|
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)
|
|
return
|
|
}
|
|
writeJSON(w, 202, j)
|
|
}
|
|
|
|
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
ents, err := s.eng.Tree(r.Context(), id, r.URL.Query().Get("path"))
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, ents)
|
|
}
|
|
|
|
type pathsBody struct {
|
|
Paths []string `json:"paths"`
|
|
}
|
|
|
|
func (s *Server) handleBuild(w http.ResponseWriter, r *http.Request) {
|
|
s.pathsJob(w, r, "build", s.eng.StartBuild)
|
|
}
|
|
|
|
func (s *Server) handleRemove(w http.ResponseWriter, r *http.Request) {
|
|
s.pathsJob(w, r, "remove from slave", s.eng.StartRemove)
|
|
}
|
|
|
|
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) {
|
|
return
|
|
}
|
|
if len(body.Paths) == 0 {
|
|
writeErr(w, 400, errors.New("no paths selected"))
|
|
return
|
|
}
|
|
for _, p := range body.Paths {
|
|
if _, err := fsops.CleanRel(p); err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
}
|
|
if !s.preAction(w, r, id, reason) {
|
|
return
|
|
}
|
|
j, err := start(id, body.Paths)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 202, j)
|
|
}
|
|
|
|
// ---- match actions ----
|
|
|
|
func (s *Server) handleLink(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
AllowDowngrade bool `json:"allow_downgrade"`
|
|
KeepFolder bool `json:"keep_folder"`
|
|
}
|
|
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)
|
|
return
|
|
}
|
|
writeJSON(w, 200, op)
|
|
}
|
|
|
|
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
Confirm bool `json:"confirm"`
|
|
}
|
|
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)
|
|
return
|
|
}
|
|
writeJSON(w, 200, op)
|
|
}
|
|
|
|
func (s *Server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
Ignored bool `json:"ignored"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
if err := s.st.SetIgnored(r.Context(), id, body.Ignored); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// ---- operations ----
|
|
|
|
func (s *Server) handleOps(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
pairID, _ := strconv.ParseInt(q.Get("pair"), 10, 64)
|
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
|
ops, total, err := s.st.Ops(r.Context(), store.OpFilter{PairID: pairID, Quarantine: q.Get("quarantine") == "1", Limit: limit, Offset: offset})
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": ops, "total": total})
|
|
}
|
|
|
|
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
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
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
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// ---- album grouping and bulk actions ----
|
|
|
|
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
q := r.URL.Query()
|
|
groups, err := s.eng.Groups(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q")})
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, groups)
|
|
}
|
|
|
|
type bulkBody struct {
|
|
engine.BulkOptions
|
|
IDs []int64 `json:"ids"`
|
|
}
|
|
|
|
func (s *Server) handleBulkPreview(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var b bulkBody
|
|
if !readJSON(w, r, &b) {
|
|
return
|
|
}
|
|
p, err := s.eng.PreviewBulk(r.Context(), id, b.IDs, b.BulkOptions)
|
|
if err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, p)
|
|
}
|
|
|
|
func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var b bulkBody
|
|
if !readJSON(w, r, &b) {
|
|
return
|
|
}
|
|
switch b.Action {
|
|
case engine.BulkIgnore, engine.BulkUnignore:
|
|
n, err := s.eng.ApplyIgnore(r.Context(), id, b.IDs, b.BulkOptions)
|
|
if err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
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)
|
|
return
|
|
}
|
|
s.log.Info("bulk action started", "pair", id, "action", b.Action, "songs", len(b.IDs), "allow_downgrade", b.AllowDowngrade)
|
|
writeJSON(w, 202, j)
|
|
}
|
|
}
|
|
|
|
// ---- folder renames ----
|
|
|
|
func (s *Server) handleRenamePreview(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
items, err := s.eng.PreviewRenames(r.Context(), id)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items, "dry_run": s.st.DryRun(r.Context())})
|
|
}
|
|
|
|
func (s *Server) handleRenames(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
// Only restricts the renames to these slave folders; empty renames all.
|
|
Only []string `json:"only"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
for _, p := range body.Only {
|
|
if _, err := fsops.CleanRel(p); err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
}
|
|
if !s.preAction(w, r, id, "rename folders") {
|
|
return
|
|
}
|
|
j, err := s.eng.StartRenames(id, body.Only)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
s.log.Info("folder renames started", "pair", id, "selected", len(body.Only))
|
|
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
|
|
}
|
|
|
|
// ---- folders without music ----
|
|
|
|
func (s *Server) handleEmptyFolders(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
writeJSON(w, 200, map[string]any{"scan": s.eng.NoMusicResult(id), "dry_run": s.st.DryRun(r.Context())})
|
|
}
|
|
|
|
func (s *Server) handleEmptyFolderScan(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
Side string `json:"side"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
j, err := s.eng.StartNoMusicScan(id, body.Side)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 202, j)
|
|
}
|
|
|
|
func (s *Server) handleRemoveEmptyFolders(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
Folders []string `json:"folders"` // must come from the latest scan
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
if !s.preAction(w, r, id, "remove folders without music") {
|
|
return
|
|
}
|
|
j, err := s.eng.StartRemoveFolders(id, body.Folders)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
s.log.Info("removing folders without music", "pair", id, "folders", len(body.Folders))
|
|
writeJSON(w, 202, j)
|
|
}
|
|
|
|
// ---- listening (build mode player) ----
|
|
|
|
var audioTypes = map[string]string{
|
|
".flac": "audio/flac", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac", ".alac": "audio/mp4",
|
|
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav", ".aif": "audio/aiff",
|
|
".aiff": "audio/aiff", ".mka": "audio/x-matroska", ".wma": "audio/x-ms-wma",
|
|
}
|
|
|
|
// handleListen streams a song with range support, so the browser can seek.
|
|
func (s *Server) handleDupes(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
writeJSON(w, 200, map[string]any{"scan": s.eng.DupeResult(id), "dry_run": s.st.DryRun(r.Context())})
|
|
}
|
|
func (s *Server) handleDupeScan(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
j, err := s.eng.StartDupeScan(id)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 202, j)
|
|
}
|
|
func (s *Server) handleRemoveDupes(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
Files []string `json:"files"` // must come from the latest search
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
if !s.preAction(w, r, id, "remove duplicate songs") {
|
|
return
|
|
}
|
|
j, err := s.eng.StartRemoveDupes(id, body.Files)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
s.log.Info("removing duplicate songs", "pair", id, "files", len(body.Files))
|
|
writeJSON(w, 202, j)
|
|
}
|
|
func (s *Server) handleListen(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
q := r.URL.Query()
|
|
f, fi, err := s.eng.OpenSong(r.Context(), id, q.Get("side"), q.Get("path"))
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
ct := audioTypes[strings.ToLower(filepath.Ext(fi.Name()))]
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Cache-Control", "private, max-age=300")
|
|
// A long song on a slow connection may take longer than the server's
|
|
// write timeout.
|
|
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})
|
|
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
|
|
}
|