Files
playlink/internal/api/server.go
T
cdnutterandClaude Opus 5.5 5f895cab6a Loosen approximate matching and link to the album version's folders
Matching:
- Prefer the studio album over singles, EPs and compilations when a song
  exists on several releases, including when the master also has the
  single at the slave's exact folder.
- Ignore release labels (Single Version, Remastered, Explicit, Deluxe)
  inside brackets or a trailing " - ..." part; "Clean" stays a version.
- Treat live/acoustic/remix/... album folders as different recordings;
  the strict top-folder fallback now respects this too.
- Add a last-resort library-wide search that only accepts candidates whose
  artist tag agrees with the slave file.

Linking:
- Cross-folder matches now mirror the master's path in the slave (e.g. a
  single moves into the album folder) and prune the emptied folder; a
  "keep in current folder" option preserves the old behaviour. Undo
  recreates and prunes folders accordingly.
- Operations record the match kind and reason, so undo (and promotes that
  rename the master file) restore the correct pairing instead of leaving
  the file unmatched until the next scan.

UI: rewrite the approximate matching description with Avenged Sevenfold
examples and clear safeguards, and show where linking will place a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 17:25:21 -07:00

569 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
}
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})
}