A new Tags tab browses either library; songs can be edited one at a time
or several at once (e.g. a whole album, where per-song fields are locked
and differing values are kept unless changed). Review's compare panel
links to the editor for either side.
Tags are read with ffprobe and written with ffmpeg (MP3/ID3, FLAC, M4A,
OGG, Opus). An existing tag is always written under the name the file
already uses, so edits replace values instead of duplicating them (e.g.
raw Vorbis ALBUMARTIST/TRACKNUMBER), totals follow the file's style
("3/12" or a separate TRACKTOTAL), stale alias tags (YEAR next to DATE)
are removed, other tags and cover art are kept, and no encoder tag is
added. Every written file is verified: values must read back and the
decoded audio must be bit-identical.
Hardlinks are respected: a file used by one library is replaced
atomically (old file to quarantine); a file shared with the other
library is updated in place so the link survives, after a verified
backup copy goes to quarantine, with an option to edit only one
library's copy instead. Edits are operations (old -> new values shown in
History) that can be undone; an interrupted in-place edit is rolled back
on startup. Dry run applies. Migration 6 stores operation details.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
742 lines
20 KiB
Go
742 lines
20 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/engine"
|
|
"github.com/cdnutter/playlink/internal/fsops"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
"github.com/cdnutter/playlink/internal/tags"
|
|
)
|
|
|
|
type Config struct {
|
|
SecureCookies bool
|
|
Version string
|
|
}
|
|
|
|
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) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
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)
|
|
|
|
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("/files", s.handleFiles)
|
|
r.Post("/tags/read", s.handleReadTags)
|
|
r.Post("/tags", s.handleEditTags)
|
|
r.Get("/renames", s.handleRenamePreview)
|
|
r.Post("/renames", s.handleRenames)
|
|
r.Post("/link-identical", s.handleLinkIdentical)
|
|
r.Get("/tree", s.handleTree)
|
|
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), errors.Is(err, engine.ErrNoTagTools):
|
|
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",
|
|
"tag_editing": s.eng.TagEditing(),
|
|
"tag_fields": tags.FieldIDs,
|
|
})
|
|
}
|
|
|
|
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)
|
|
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, s.eng.StartBuild)
|
|
}
|
|
|
|
func (s *Server) handleRemove(w http.ResponseWriter, r *http.Request) {
|
|
s.pathsJob(w, r, s.eng.StartRemove)
|
|
}
|
|
|
|
func (s *Server) pathsJob(w http.ResponseWriter, r *http.Request, 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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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 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 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:
|
|
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
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// ---- tag editor ----
|
|
|
|
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
q := r.URL.Query()
|
|
ents, err := s.eng.ListFiles(r.Context(), id, q.Get("side"), q.Get("path"))
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, ents)
|
|
}
|
|
|
|
func (s *Server) handleReadTags(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body struct {
|
|
Side string `json:"side"`
|
|
Paths []string `json:"paths"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
files, err := s.eng.ReadTags(r.Context(), id, body.Side, body.Paths)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"files": files})
|
|
}
|
|
|
|
func (s *Server) handleEditTags(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := idParam(r)
|
|
var body engine.TagEdit
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
j, err := s.eng.StartTagEdit(id, body)
|
|
if err != nil {
|
|
opErr(w, err)
|
|
return
|
|
}
|
|
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)
|
|
}
|