A per-pair "Approximate matching" setting adds a second pass for slave files the strict rules could not place. It searches the same album with disc folders (Disc 02, CD2, ...) ignored, then the top-level folder, and scores titles by edit distance and word containment against a configurable threshold (default 85%), with a small track-number bonus. Guards against false matches: version words (live, remix, reprise, instrumental, ...) must agree, near-tied candidates are left unmatched, and approximate matches always go to manual review, never auto-link. Each match records a human-readable reason. Review now flags matches where the master copy is better as upgrades, with an Upgrades filter and count and an "Upgrade to master copy" action. The redundant "master is lossless" warning note is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
565 lines
15 KiB
Go
565 lines
15 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"
|
|
)
|
|
|
|
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.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):
|
|
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()),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, map[string]any{"dry_run": s.st.DryRun(r.Context())})
|
|
}
|
|
|
|
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
DryRun *bool `json:"dry_run"`
|
|
}
|
|
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)
|
|
}
|
|
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": []string{}})
|
|
return
|
|
}
|
|
if warns == nil {
|
|
warns = []string{}
|
|
}
|
|
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 = []string{}
|
|
}
|
|
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"))
|
|
ms, total, err := s.st.Matches(r.Context(), id, store.MatchFilter{Status: q.Get("status"), Query: q.Get("q"), Limit: limit, Offset: offset})
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
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"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
op, err := s.eng.Link(r.Context(), id, engine.LinkOptions{AllowDowngrade: body.AllowDowngrade})
|
|
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})
|
|
}
|