Files
playlink/internal/api/server.go
T
cdnutterandClaude Opus 5.5 381b804742 Add opt-in approximate matching and upgrade recommendations
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>
2026-09-22 16:58:53 -07:00

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