From ac47e61b080c0f81f5ce262953a668a6c1b00f72 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 22 Sep 2026 15:38:25 -0700 Subject: [PATCH] Initial commit: playlink music library hardlink deduplicator Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds songs present in both a host and a slave music library and replaces the slave copy with a hardlink to the host copy. - Path-first matching with format/title/album-folder fallbacks - Auto-linking of byte-identical files, re-verified with cmp before linking - Manual review with ffprobe quality comparison, downgrade guard and promote-to-host override - Build mode to populate an empty slave library from the host - Dry run by default, atomic stage+rename replacement, quarantine with retention, undo, path confinement and authenticated UI - Dockerfile and compose file for Unraid Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 5 + .gitignore | 9 + Dockerfile | 31 + Makefile | 18 + README.md | 129 +++ cmd/playlink/main.go | 153 +++ docker-compose.yml | 22 + go.mod | 21 + go.sum | 54 + internal/api/auth.go | 281 +++++ internal/api/server.go | 561 +++++++++ internal/engine/engine.go | 352 ++++++ internal/engine/engine_test.go | 481 ++++++++ internal/engine/ops.go | 834 ++++++++++++++ internal/engine/scan.go | 448 ++++++++ internal/fsops/fsops.go | 405 +++++++ internal/probe/probe.go | 219 ++++ internal/probe/probe_test.go | 57 + internal/store/models.go | 651 +++++++++++ internal/store/store.go | 162 +++ web/dist/.gitkeep | 0 web/embed.go | 19 + web/index.html | 13 + web/package-lock.json | 1949 ++++++++++++++++++++++++++++++++ web/package.json | 26 + web/public/favicon.svg | 1 + web/src/App.tsx | 124 ++ web/src/api.ts | 160 +++ web/src/index.css | 14 + web/src/main.tsx | 20 + web/src/pages/Activity.tsx | 181 +++ web/src/pages/Build.tsx | 136 +++ web/src/pages/Dashboard.tsx | 116 ++ web/src/pages/Login.tsx | 60 + web/src/pages/PairForm.tsx | 221 ++++ web/src/pages/PairPage.tsx | 221 ++++ web/src/pages/Review.tsx | 398 +++++++ web/src/pages/Settings.tsx | 93 ++ web/src/toast.tsx | 36 + web/src/ui.tsx | 185 +++ web/tsconfig.json | 17 + web/vite.config.ts | 9 + 42 files changed, 8892 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/playlink/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/auth.go create mode 100644 internal/api/server.go create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/engine/ops.go create mode 100644 internal/engine/scan.go create mode 100644 internal/fsops/fsops.go create mode 100644 internal/probe/probe.go create mode 100644 internal/probe/probe_test.go create mode 100644 internal/store/models.go create mode 100644 internal/store/store.go create mode 100644 web/dist/.gitkeep create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/Activity.tsx create mode 100644 web/src/pages/Build.tsx create mode 100644 web/src/pages/Dashboard.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/PairForm.tsx create mode 100644 web/src/pages/PairPage.tsx create mode 100644 web/src/pages/Review.tsx create mode 100644 web/src/pages/Settings.tsx create mode 100644 web/src/toast.tsx create mode 100644 web/src/ui.tsx create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9aa71ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +web/node_modules +web/dist +config +*.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b3cec3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/playlink +/config/ +*.db +*.db-wal +*.db-shm +web/node_modules/ +web/dist/* +!web/dist/.gitkeep +web/*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..159a6ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS web +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.27-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=web /src/web/dist ./web/dist +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/playlink ./cmd/playlink + +FROM alpine:3.22 +# ffprobe (from ffmpeg) powers the codec/quality comparison in manual review. +RUN apk add --no-cache ffmpeg tzdata ca-certificates +COPY --from=build /out/playlink /usr/local/bin/playlink +ENV PLAYLINK_ADDR=:7480 \ + PLAYLINK_DATA_DIR=/config \ + PLAYLINK_ALLOWED_ROOTS=/data +# Unraid's nobody:users. Override with `user:` in compose to match your file ownership. +USER 99:100 +VOLUME /config +EXPOSE 7480 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:7480/healthz || exit 1 +ENTRYPOINT ["/usr/local/bin/playlink"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fbe6db7 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: build web test run docker + +build: web + CGO_ENABLED=0 go build -trimpath -o playlink ./cmd/playlink + +web: + cd web && npm ci && npm run build + +test: + go vet ./... + go test -race ./... + cd web && npx tsc -b + +run: build + PLAYLINK_DATA_DIR=./config PLAYLINK_ALLOWED_ROOTS=$(PWD)/testdata ./playlink + +docker: + docker build -t playlink:latest . diff --git a/README.md b/README.md new file mode 100644 index 0000000..db0d427 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# playlink + +Deduplicate music libraries with hardlinks. playlink finds songs that exist in both a **host** library (e.g. your full `Albums`) and a +**slave** library (e.g. a curated `Singles` library) and replaces the slave copy with a hardlink to the host copy. Each song then takes +up disk space only once, while both libraries keep looking exactly the same to Plex, Navidrome, Jellyfin and other media servers. + +It ships as a single Go binary with an embedded React web UI and SQLite state, packaged as a small Docker image, in the style of +[qui](https://github.com/autobrr/qui). + +## What it does + +| Situation | What playlink does | +|---|---| +| Same `Artist/Album/Title.ext` path, **byte-identical** | Status `identical`. Link with one click, in bulk, or automatically after each scan. | +| Same path, **different bytes** (re-encode, different tags) | Status `different`. Manual review with a codec/bitrate/bit-depth/duration comparison. | +| Same title, **different format** (`Song.mp3` vs `Song.flac`) | Status `similar` (other format). Manual review. | +| Same title after ignoring track numbers/punctuation (`01 - Song!.flac` vs `Song.mp3`) | Status `similar` (similar title). Manual review. | +| Same artist and title, but in a **different album folder** | Status `similar` (other album folder). Manual review, plus a layout warning. | +| Only exists in the slave library | Status `unmatched`. Never touched. | +| Already the same inode | Status `linked`. | + +**Manual review** shows both files side by side and ranks their quality: lossless beats lossy, then bit depth and sample rate, then +codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then have three choices: + +- **Link to host copy.** The default. The host is kept because it belongs to a complete album. If the host is *lower* quality, playlink + warns you and asks for explicit confirmation. +- **Use slave copy as host.** The override. The slave's better file replaces the host file, the old host file is quarantined, and both + libraries share the better file. +- **Ignore.** The pair is hidden from review until you un-ignore it. + +**Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the host library by artist and album, tick +what you want, and playlink hardlinks it in at the same path. **Remove** deletes slave files, but only ones that are hardlinks of the +host file. + +## Safety model + +playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation: + +- **Dry run is on by default.** Every action is recorded in Activity as *planned* until you switch to live mode. +- **Files are re-verified before any change.** Both files must still be plain regular files with the same inode, size and mtime that + the scan recorded, or the action is refused ("changed since scan"). +- **Identical files are compared again right before linking.** The check is a full byte-for-byte compare, like `cmp`, and it also + detects files that change while being read. +- **Replacements are atomic.** The new link is staged next to the target (`.playlink-tmp-*`), checked by inode, and then `rename(2)`d + over the target. The path always points at a complete file. +- **Nothing non-identical is ever deleted directly.** Before a non-identical file is replaced, a hardlink to it is created in the + quarantine (`/.playlink-trash//…`). It can be restored with one click until the retention period (default 30 days) + expires, or until you purge it. +- **Every operation can be undone.** An identical link is undone by writing an independent, verified copy back. A quarantined file is + undone by restoring the original. +- **Removal is limited to hardlinks.** Build/Remove only deletes a slave file that shares an inode with the host file, so the last + copy of any data is never deleted. +- **Paths are confined.** Libraries and the quarantine must be inside `PLAYLINK_ALLOWED_ROOTS`. Symlinks are never followed or + replaced, `..` is rejected, and host and slave may not overlap. +- **Mutations are serialized.** Only one filesystem mutation runs at a time, and only one job runs per pair. +- **Auto-link is conservative.** It only acts on files with the *exact* same path, and it skips files modified within the settle time + (default 10 minutes) so it never touches a download in progress. +- **Interrupted operations are cleaned up.** Operations cut short by a crash are marked *interrupted* on startup, and their staging + files are removed. +- **The web UI requires login.** Passwords are hashed with bcrypt, sessions use `SameSite=Strict` HttpOnly cookies, and state-changing + requests need a custom request header and a same-origin check. Logins are rate-limited, and a strict CSP is sent. + +## Quick start (Docker / Unraid) + +```yaml +services: + playlink: + build: . # or your published image + container_name: playlink + user: "99:100" # owner of your music files (Unraid nobody:users) + ports: ["7480:7480"] + environment: + - PLAYLINK_ALLOWED_ROOTS=/data + volumes: + - /mnt/user/appdata/playlink:/config + - /mnt/user/Sapphire/Media/Music:/data/Music # ONE mount containing both libraries + restart: unless-stopped +``` + +Then open `http://:7480`, create your account, and add a pair: + +- Host: `/data/Music/Albums` +- Slave: `/data/Music/Instances/cdnutter/Singles` + +Run a scan, check the Review tab, and turn off dry run once the planned actions in Activity look right. + +### Hardlink requirements + +- **Both libraries must be visible through one bind mount.** Docker bind mounts are separate mount points, and `link(2)` fails across + them (`EXDEV`) even when they are on the same disk. Mount a common parent folder, as shown above. +- **Both libraries must be on the same filesystem.** playlink checks this when you save a pair and before every operation. +- **Unraid:** hardlinks through `/mnt/user` require *Settings → Global Share Settings → Tunable (support Hard Links)* to be *Yes* + (the default). If the share uses a cache pool, make sure your mover preserves hardlinks. Every link is verified by inode, and a + failed link aborts safely with the original untouched. + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `PLAYLINK_ADDR` | `:7480` | Listen address. | +| `PLAYLINK_DATA_DIR` | `/config` (Docker) or `./config` | Where the SQLite database lives. | +| `PLAYLINK_ALLOWED_ROOTS` | `/data` | Comma-separated folders that libraries and the quarantine must live in. `/` is refused. | +| `PLAYLINK_SECURE_COOKIES` | `false` | Set to `true` when served over HTTPS. | +| `PLAYLINK_FFPROBE` | `ffprobe` | Path to ffprobe. Without it, quality comparison is disabled but everything else works. | +| `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. | + +Per-pair settings (in the UI) are file extensions, auto-link, scan interval, quarantine folder, retention and settle time. + +## Development + +Requires Go 1.27+ and Node 22+. + +```sh +make test # go vet, go test -race, tsc +make build # builds web/dist and the ./playlink binary (UI embedded) +cd web && npm run dev # Vite dev server on :5173, proxies /api to :7480 +``` + +Layout: + +``` +cmd/playlink entrypoint, config, scheduler +internal/fsops safety-critical primitives (confined paths, cmp, atomic link/replace) +internal/engine scanning, matching, link/promote/build/remove/undo, jobs +internal/probe ffprobe wrapper and quality ranking +internal/store SQLite schema and queries +internal/api HTTP API, auth, embedded UI serving +web/ React + TypeScript + Tailwind UI +``` diff --git a/cmd/playlink/main.go b/cmd/playlink/main.go new file mode 100644 index 0000000..5e48476 --- /dev/null +++ b/cmd/playlink/main.go @@ -0,0 +1,153 @@ +// Command playlink deduplicates music libraries with hardlinks. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/cdnutter/playlink/internal/api" + "github.com/cdnutter/playlink/internal/engine" + "github.com/cdnutter/playlink/internal/probe" + "github.com/cdnutter/playlink/internal/store" + "github.com/cdnutter/playlink/web" +) + +var version = "dev" + +func env(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func main() { + level := slog.LevelInfo + if env("PLAYLINK_LOG_LEVEL", "info") == "debug" { + level = slog.LevelDebug + } + log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + + addr := env("PLAYLINK_ADDR", ":7480") + dataDir := env("PLAYLINK_DATA_DIR", "./config") + secure := env("PLAYLINK_SECURE_COOKIES", "false") == "true" + + // Every library, quarantine and folder-picker path must live under one of + // these roots. In Docker this is the mounted music volume. + var roots []string + for _, r := range strings.Split(env("PLAYLINK_ALLOWED_ROOTS", "/data"), ",") { + r = strings.TrimSpace(r) + if r == "" { + continue + } + if !filepath.IsAbs(r) { + log.Error("allowed root must be absolute", "root", r) + os.Exit(1) + } + real, err := filepath.EvalSymlinks(filepath.Clean(r)) + if err != nil { + log.Warn("allowed root is not accessible", "root", r, "err", err) + continue + } + if real == "/" { + log.Error("refusing to allow the filesystem root; set PLAYLINK_ALLOWED_ROOTS to your music folder") + os.Exit(1) + } + roots = append(roots, real) + } + if len(roots) == 0 { + log.Error("no usable PLAYLINK_ALLOWED_ROOTS") + os.Exit(1) + } + + ffprobe := env("PLAYLINK_FFPROBE", "ffprobe") + if p, err := exec.LookPath(ffprobe); err == nil { + ffprobe = p + } else { + log.Warn("ffprobe not found; quality comparison disabled", "bin", ffprobe) + ffprobe = "" + } + + if err := os.MkdirAll(dataDir, 0o750); err != nil { + log.Error("create data dir", "err", err) + os.Exit(1) + } + st, err := store.Open(dataDir) + if err != nil { + log.Error("open database", "err", err) + os.Exit(1) + } + defer st.DB.Close() + + eng := engine.New(st, probe.Prober{Bin: ffprobe}, roots, log) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + eng.Recover(ctx) + go scheduler(ctx, st, eng, log) + + srv := &http.Server{ + Addr: addr, + Handler: api.New(api.Config{SecureCookies: secure, Version: version}, st, eng, log, web.Dist()).Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 2 * time.Minute, + } + go func() { + <-ctx.Done() + shut, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shut) + }() + log.Info("playlink listening", "addr", addr, "version", version, "allowed_roots", roots, "data", dataDir, + "dry_run", st.DryRun(ctx)) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("server", "err", err) + os.Exit(1) + } +} + +// scheduler starts interval scans and hourly maintenance. +func scheduler(ctx context.Context, st *store.Store, eng *engine.Engine, log *slog.Logger) { + tick := time.NewTicker(time.Minute) + defer tick.Stop() + lastMaint := time.Time{} + for { + if time.Since(lastMaint) >= time.Hour { + eng.Maintenance(ctx) + lastMaint = time.Now() + } + if pairs, err := st.Pairs(ctx); err == nil { + for _, p := range pairs { + if p.ScanIntervalMinutes <= 0 { + continue + } + due := p.LastScanAt == nil || time.Since(time.Unix(*p.LastScanAt, 0)) >= time.Duration(p.ScanIntervalMinutes)*time.Minute + if !due { + continue + } + if j := eng.Job(p.ID); j != nil && j.State == "running" { + continue + } + if _, err := eng.StartScan(p.ID, "schedule"); err == nil { + log.Info("scheduled scan started", "pair", p.Name) + } + } + } + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2aaf1d2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + playlink: + build: . + image: playlink:latest + container_name: playlink + # Run as the user that owns your music (Unraid: nobody:users = 99:100). + user: "99:100" + ports: + - "7480:7480" + environment: + - TZ=America/Los_Angeles + - PLAYLINK_ALLOWED_ROOTS=/data + # Set to true when serving over HTTPS (e.g. behind a reverse proxy). + - PLAYLINK_SECURE_COOKIES=false + volumes: + - /mnt/user/appdata/playlink:/config + # IMPORTANT: mount ONE folder that contains BOTH libraries. Hardlinks + # cannot cross Docker bind mounts, even on the same disk. + # host (Albums) -> /data/Music/Albums + # slave (Singles) -> /data/Music/Instances/cdnutter/Singles + - /mnt/user/Sapphire/Media/Music:/data/Music + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7be954a --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/cdnutter/playlink + +go 1.27.1 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + golang.org/x/crypto v0.57.0 + modernc.org/sqlite v1.59.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7691ac4 --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..630ae93 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,281 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + + "github.com/cdnutter/playlink/internal/store" +) + +const ( + cookieName = "playlink_session" + sessionTTL = 30 * 24 * time.Hour + csrfHeader = "X-Playlink-Request" +) + +type ctxKey int + +const userKey ctxKey = 1 + +func hashToken(t string) string { + h := sha256.Sum256([]byte(t)) + return hex.EncodeToString(h[:]) +} + +func newToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// limiter is a small per-IP fixed-window limiter for login attempts. +type limiter struct { + mu sync.Mutex + hits map[string][]time.Time + max int + window time.Duration +} + +func newLimiter(max int, window time.Duration) *limiter { + return &limiter{hits: map[string][]time.Time{}, max: max, window: window} +} + +func (l *limiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + kept := l.hits[key][:0] + for _, t := range l.hits[key] { + if now.Sub(t) < l.window { + kept = append(kept, t) + } + } + if len(kept) >= l.max { + l.hits[key] = kept + return false + } + l.hits[key] = append(kept, now) + return true +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, uid int64) error { + tok, err := newToken() + if err != nil { + return err + } + exp := time.Now().Add(sessionTTL) + if err := s.st.CreateSession(r.Context(), hashToken(tok), uid, exp.Unix()); err != nil { + return err + } + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", Expires: exp, HttpOnly: true, + Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode}) + return nil +} + +func (s *Server) currentUser(r *http.Request) (store.User, string, error) { + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + return store.User{}, "", store.ErrNotFound + } + th := hashToken(c.Value) + uid, err := s.st.SessionUser(r.Context(), th) + if err != nil { + return store.User{}, "", err + } + u, err := s.st.UserByID(r.Context(), uid) + return u, th, err +} + +// requireAuth rejects unauthenticated requests and, for state-changing +// methods, requests that are not same-origin API calls (CSRF defence in +// addition to the SameSite=Strict cookie). +func (s *Server) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, _, err := s.currentUser(r) + if err != nil { + writeErr(w, http.StatusUnauthorized, errors.New("not signed in")) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u))) + }) +} + +func (s *Server) csrf(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + default: + if r.Header.Get(csrfHeader) != "1" { + writeErr(w, http.StatusForbidden, errors.New("missing request header")) + return + } + if o := r.Header.Get("Origin"); o != "" { + u, err := url.Parse(o) + if err != nil || !strings.EqualFold(u.Host, r.Host) { + writeErr(w, http.StatusForbidden, errors.New("cross-origin request refused")) + return + } + } + } + next.ServeHTTP(w, r) + }) +} + +type credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func validPassword(p string) error { + if len(p) < 10 { + return errors.New("password must be at least 10 characters") + } + if len(p) > 72 { + return errors.New("password must be at most 72 bytes") + } + return nil +} + +func (s *Server) handleAuthState(w http.ResponseWriter, r *http.Request) { + n, err := s.st.UserCount(r.Context()) + if err != nil { + writeErr(w, 500, err) + return + } + resp := map[string]any{"setup_required": n == 0, "authenticated": false} + if u, _, err := s.currentUser(r); err == nil { + resp["authenticated"] = true + resp["username"] = u.Username + } + writeJSON(w, 200, resp) +} + +func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { + var c credentials + if !readJSON(w, r, &c) { + return + } + c.Username = strings.TrimSpace(c.Username) + if c.Username == "" { + writeErr(w, 400, errors.New("username is required")) + return + } + if err := validPassword(c.Password); err != nil { + writeErr(w, 400, err) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), 12) + if err != nil { + writeErr(w, 500, err) + return + } + if err := s.st.CreateFirstUser(r.Context(), c.Username, string(hash)); err != nil { + writeErr(w, 409, err) + return + } + u, err := s.st.UserByName(r.Context(), c.Username) + if err != nil { + writeErr(w, 500, err) + return + } + if err := s.startSession(w, r, u.ID); err != nil { + writeErr(w, 500, err) + return + } + s.log.Info("initial user created", "user", u.Username) + writeJSON(w, 200, map[string]any{"ok": true}) +} + +// dummyHash keeps login timing similar for unknown usernames. +var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("playlink-timing-equalizer"), 12) + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if !s.loginLimit.allow(clientIP(r)) { + writeErr(w, http.StatusTooManyRequests, errors.New("too many attempts; try again in a few minutes")) + return + } + var c credentials + if !readJSON(w, r, &c) { + return + } + u, err := s.st.UserByName(r.Context(), strings.TrimSpace(c.Username)) + hash := dummyHash + if err == nil { + hash = []byte(u.PasswordHash) + } + if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil { + s.log.Warn("failed login", "ip", clientIP(r)) + writeErr(w, http.StatusUnauthorized, errors.New("invalid username or password")) + return + } + if err := s.startSession(w, r, u.ID); err != nil { + writeErr(w, 500, err) + return + } + writeJSON(w, 200, map[string]any{"ok": true}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(cookieName); err == nil { + _ = s.st.DeleteSession(r.Context(), hashToken(c.Value)) + } + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, + Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode}) + writeJSON(w, 200, map[string]any{"ok": true}) +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + var body struct { + Current string `json:"current"` + New string `json:"new"` + } + if !readJSON(w, r, &body) { + return + } + u, th, err := s.currentUser(r) + if err != nil { + writeErr(w, 401, err) + return + } + if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Current)) != nil { + writeErr(w, 400, errors.New("current password is incorrect")) + return + } + if err := validPassword(body.New); err != nil { + writeErr(w, 400, err) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(body.New), 12) + if err != nil { + writeErr(w, 500, err) + return + } + if err := s.st.SetPassword(r.Context(), u.ID, string(hash)); err != nil { + writeErr(w, 500, err) + return + } + _ = s.st.DeleteUserSessions(r.Context(), u.ID, th) // sign out other devices + writeJSON(w, 200, map[string]any{"ok": true}) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..5d92822 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,561 @@ +// 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"` + HostRoot string `json:"host_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"` +} + +func (in pairInput) apply(p *store.Pair) { + p.Name, p.HostRoot, p.SlaveRoot, p.QuarantineDir = in.Name, in.HostRoot, in.SlaveRoot, in.QuarantineDir + p.Extensions, p.AutoLinkIdentical = in.Extensions, in.AutoLinkIdentical + p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes = in.ScanIntervalMinutes, in.RetentionDays, in.SettleMinutes +} + +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.HostRoot != p.HostRoot || 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}) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..1787eb0 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,352 @@ +// Package engine implements scanning, matching and the hardlink operations. +package engine + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/probe" + "github.com/cdnutter/playlink/internal/store" +) + +// DefaultExtensions are the audio file types considered by default. +var DefaultExtensions = []string{"flac", "mp3", "m4a", "aac", "alac", "ogg", "oga", "opus", "wav", "aif", "aiff", "wma", "ape", "wv", "dsf", "dff", "mka"} + +// QuarantineDirName is the default quarantine folder created in the slave root. +const QuarantineDirName = ".playlink-trash" + +var ( + ErrBusy = errors.New("another job is already running for this library pair") + ErrDowngrade = errors.New("the host copy is lower quality than the slave copy; confirm the downgrade to continue") + ErrConfirm = errors.New("this action modifies the host library and must be confirmed") +) + +type Engine struct { + st *store.Store + prober probe.Prober + allowedRoots []string + log *slog.Logger + + // mu serializes every filesystem mutation across all pairs. Throughput + // is irrelevant next to the guarantee that two operations never race on + // the same files. + mu sync.Mutex + + jobsMu sync.Mutex + jobs map[int64]*Job + nextID int64 +} + +func New(st *store.Store, prober probe.Prober, allowedRoots []string, log *slog.Logger) *Engine { + return &Engine{st: st, prober: prober, allowedRoots: allowedRoots, log: log, jobs: map[int64]*Job{}} +} + +func (e *Engine) AllowedRoots() []string { return e.allowedRoots } + +// ---- jobs ---- + +type Job struct { + ID int64 `json:"id"` + PairID int64 `json:"pair_id"` + Kind string `json:"kind"` + State string `json:"state"` // running, done, failed, cancelled + Phase string `json:"phase"` + Done int64 `json:"done"` + Total int64 `json:"total"` + Message string `json:"message"` + Error string `json:"error"` + Results map[string]int `json:"results"` + Skipped []string `json:"skipped"` + StartedAt int64 `json:"started_at"` + FinishedAt int64 `json:"finished_at,omitempty"` + + cancel context.CancelFunc + e *Engine +} + +func (j *Job) update(f func(j *Job)) { + j.e.jobsMu.Lock() + f(j) + j.e.jobsMu.Unlock() +} + +func (j *Job) phase(p string, total int64) { + j.update(func(j *Job) { j.Phase, j.Total, j.Done = p, total, 0 }) +} + +func (j *Job) tick() { j.update(func(j *Job) { j.Done++ }) } + +func (j *Job) count(key string) { j.update(func(j *Job) { j.Results[key]++ }) } + +func (j *Job) skip(msg string) { + j.update(func(j *Job) { + j.Results["skipped"]++ + if len(j.Skipped) < 200 { + j.Skipped = append(j.Skipped, msg) + } + }) +} + +// Job returns a snapshot of the latest job for a pair. +func (e *Engine) Job(pairID int64) *Job { + e.jobsMu.Lock() + defer e.jobsMu.Unlock() + j, ok := e.jobs[pairID] + if !ok { + return nil + } + cp := *j + cp.Results = map[string]int{} + for k, v := range j.Results { + cp.Results[k] = v + } + cp.Skipped = append([]string{}, j.Skipped...) // never nil: the UI expects an array + return &cp +} + +func (e *Engine) CancelJob(pairID int64) bool { + e.jobsMu.Lock() + defer e.jobsMu.Unlock() + if j, ok := e.jobs[pairID]; ok && j.State == "running" { + j.cancel() + return true + } + return false +} + +func (e *Engine) startJob(pairID int64, kind string, run func(ctx context.Context, j *Job) error) (*Job, error) { + e.jobsMu.Lock() + if j, ok := e.jobs[pairID]; ok && j.State == "running" { + e.jobsMu.Unlock() + return nil, ErrBusy + } + e.nextID++ + ctx, cancel := context.WithCancel(context.Background()) + j := &Job{ID: e.nextID, PairID: pairID, Kind: kind, State: "running", Results: map[string]int{}, + Skipped: []string{}, StartedAt: time.Now().Unix(), cancel: cancel, e: e} + e.jobs[pairID] = j + e.jobsMu.Unlock() + + go func() { + defer cancel() + err := run(ctx, j) + j.update(func(j *Job) { + j.FinishedAt = time.Now().Unix() + switch { + case errors.Is(err, context.Canceled): + j.State, j.Error = "cancelled", "cancelled" + case err != nil: + j.State, j.Error = "failed", err.Error() + default: + j.State = "done" + } + }) + if err != nil { + e.log.Warn("job finished with error", "pair", pairID, "kind", kind, "err", err) + } else { + e.log.Info("job finished", "pair", pairID, "kind", kind) + } + }() + return e.Job(pairID), nil +} + +// ---- pair validation ---- + +type libs struct { + pair store.Pair + host fsops.Root + slave fsops.Root + quarantine string + dev uint64 + exts map[string]bool +} + +func (l *libs) hasExt(name string) bool { + return l.exts[strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".")] +} + +func (e *Engine) withinAllowed(p string) bool { + for _, r := range e.allowedRoots { + if fsops.Within(r, p) { + return true + } + } + return false +} + +// open resolves a pair's directories and re-checks every invariant. It is +// called before every scan and operation so configuration drift (a remount, +// a moved folder) is caught before anything is touched. +func (e *Engine) open(p store.Pair) (*libs, error) { + host, err := fsops.NewRoot(p.HostRoot) + if err != nil { + return nil, fmt.Errorf("host library: %w", err) + } + slave, err := fsops.NewRoot(p.SlaveRoot) + if err != nil { + return nil, fmt.Errorf("slave library: %w", err) + } + for _, r := range []string{host.Path, slave.Path} { + if !e.withinAllowed(r) { + return nil, fmt.Errorf("%s is outside the allowed roots %v", r, e.allowedRoots) + } + } + if fsops.Within(host.Path, slave.Path) || fsops.Within(slave.Path, host.Path) { + return nil, errors.New("host and slave libraries must not overlap or contain each other") + } + hi, err := fsops.Lstat(host.Path) + if err != nil { + return nil, err + } + si, err := fsops.Lstat(slave.Path) + if err != nil { + return nil, err + } + if hi.Dev != si.Dev { + return nil, fmt.Errorf("host and slave are on different filesystems (device %d vs %d): %w. "+ + "In Docker, mount a single common parent folder instead of two separate volumes", hi.Dev, si.Dev, fsops.ErrCrossDevice) + } + + q := filepath.Clean(p.QuarantineDir) + if !filepath.IsAbs(q) { + return nil, errors.New("quarantine directory must be absolute") + } + if fsops.Within(host.Path, q) { + return nil, errors.New("quarantine directory must not be inside the host library") + } + if q == slave.Path || !e.withinAllowed(q) { + return nil, errors.New("quarantine directory must be inside the allowed roots and not the slave root itself") + } + if err := os.MkdirAll(q, 0o775); err != nil { + return nil, fmt.Errorf("quarantine directory: %w", err) + } + if rq, err := filepath.EvalSymlinks(q); err != nil || (rq != q && !e.withinAllowed(rq)) { + return nil, errors.New("quarantine directory resolves outside the allowed roots") + } + qi, err := fsops.Lstat(q) + if err != nil { + return nil, err + } + if qi.Dev != hi.Dev { + return nil, errors.New("quarantine directory must be on the same filesystem as the libraries") + } + + exts := map[string]bool{} + for _, x := range p.Extensions { + x = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(x)), ".") + if x != "" { + exts[x] = true + } + } + if len(exts) == 0 { + return nil, errors.New("no file extensions configured") + } + return &libs{pair: p, host: host, slave: slave, quarantine: q, dev: hi.Dev, exts: exts}, nil +} + +// NormalizePair fills defaults and validates a pair. It returns non-fatal +// warnings (such as layout mismatches) for the UI. +func (e *Engine) NormalizePair(p *store.Pair) ([]string, error) { + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + return nil, errors.New("name is required") + } + p.HostRoot = filepath.Clean(strings.TrimSpace(p.HostRoot)) + p.SlaveRoot = filepath.Clean(strings.TrimSpace(p.SlaveRoot)) + if strings.TrimSpace(p.QuarantineDir) == "" { + p.QuarantineDir = filepath.Join(p.SlaveRoot, QuarantineDirName) + } + p.QuarantineDir = filepath.Clean(strings.TrimSpace(p.QuarantineDir)) + if len(p.Extensions) == 0 || (len(p.Extensions) == 1 && strings.TrimSpace(p.Extensions[0]) == "") { + p.Extensions = DefaultExtensions + } + var clean []string + for _, x := range p.Extensions { + x = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(x)), ".") + if x != "" && !strings.ContainsAny(x, ",/") { + clean = append(clean, x) + } + } + p.Extensions = clean + if p.RetentionDays < 0 || p.ScanIntervalMinutes < 0 || p.SettleMinutes < 0 { + return nil, errors.New("intervals must not be negative") + } + if p.ScanIntervalMinutes > 0 && p.ScanIntervalMinutes < 5 { + return nil, errors.New("scan interval must be 0 (manual) or at least 5 minutes") + } + l, err := e.open(*p) + if err != nil { + return nil, err + } + // Store resolved paths so later containment checks compare like with like. + p.HostRoot, p.SlaveRoot, p.QuarantineDir = l.host.Path, l.slave.Path, l.quarantine + return layoutWarnings(l), nil +} + +// layoutWarnings does a cheap top-level comparison of the two libraries. +func layoutWarnings(l *libs) []string { + var warns []string + list := func(root string) (dirs map[string]bool, looseFiles int) { + dirs = map[string]bool{} + ents, _ := os.ReadDir(root) + for _, en := range ents { + if strings.HasPrefix(en.Name(), ".") { + continue + } + if en.IsDir() { + dirs[strings.ToLower(en.Name())] = true + } else if l.hasExt(en.Name()) { + looseFiles++ + } + } + return + } + hd, hl := list(l.host.Path) + sd, sl := list(l.slave.Path) + if len(sd) == 0 && sl == 0 { + warns = append(warns, "The slave library is empty. Use Build to pick songs from the host library to hardlink into it.") + return warns + } + if hl > 0 || sl > 0 { + warns = append(warns, fmt.Sprintf("Found audio files directly in a library root (host: %d, slave: %d). Both libraries should use Artist/Album/Title.ext.", hl, sl)) + } + shared := 0 + for d := range sd { + if hd[d] { + shared++ + } + } + if len(sd) > 0 && shared*2 < len(sd) { + warns = append(warns, fmt.Sprintf("Only %d of %d artist folders in the slave library also exist in the host library. "+ + "The two libraries should share the same Artist/Album/Title.ext layout, or most files will not match.", shared, len(sd))) + } + return warns +} + +// ---- helpers ---- + +func (e *Engine) loadPair(ctx context.Context, id int64) (*libs, error) { + p, err := e.st.Pair(ctx, id) + if err != nil { + return nil, err + } + return e.open(p) +} + +func sortedKeys(m map[string]fsops.Info) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..d4b87d1 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,481 @@ +package engine + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/probe" + "github.com/cdnutter/playlink/internal/store" +) + +type env struct { + t *testing.T + root string + host string + slave string + st *store.Store + e *Engine + pair store.Pair +} + +func setup(t *testing.T, dryRun bool) *env { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + ev := &env{t: t, root: root, host: filepath.Join(root, "Albums"), slave: filepath.Join(root, "Singles")} + for _, d := range []string{ev.host, ev.slave, filepath.Join(root, "db")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + st, err := store.Open(filepath.Join(root, "db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.DB.Close() }) + v := "0" + if dryRun { + v = "1" + } + if err := st.SetSetting(context.Background(), "dry_run", v); err != nil { + t.Fatal(err) + } + ev.st = st + ev.e = New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil))) + ev.pair = store.Pair{Name: "test", HostRoot: ev.host, SlaveRoot: ev.slave, RetentionDays: 30} + if _, err := ev.e.NormalizePair(&ev.pair); err != nil { + t.Fatal(err) + } + if err := st.SavePair(context.Background(), &ev.pair); err != nil { + t.Fatal(err) + } + return ev +} + +func (ev *env) write(base, rel, content string) string { + ev.t.Helper() + p := filepath.Join(base, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + ev.t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + ev.t.Fatal(err) + } + return p +} + +func (ev *env) read(p string) string { + ev.t.Helper() + b, err := os.ReadFile(p) + if err != nil { + ev.t.Fatal(err) + } + return string(b) +} + +func (ev *env) scan() { + ev.t.Helper() + if _, err := ev.e.StartScan(ev.pair.ID, "test"); err != nil { + ev.t.Fatal(err) + } + ev.wait() +} + +func (ev *env) wait() *Job { + ev.t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if j := ev.e.Job(ev.pair.ID); j != nil && j.State != "running" { + if j.State != "done" { + ev.t.Fatalf("job %s: %s", j.State, j.Error) + } + return j + } + time.Sleep(10 * time.Millisecond) + } + ev.t.Fatal("job did not finish") + return nil +} + +func (ev *env) match(rel string) store.Match { + ev.t.Helper() + m, err := ev.st.MatchBySlave(context.Background(), ev.pair.ID, rel) + if err != nil { + ev.t.Fatalf("match %s: %v", rel, err) + } + return m +} + +func sameInode(t *testing.T, a, b string) bool { + t.Helper() + ia, err := fsops.Lstat(a) + if err != nil { + t.Fatal(err) + } + ib, err := fsops.Lstat(b) + if err != nil { + t.Fatal(err) + } + return ia.SameInode(ib) +} + +var ctx = context.Background() + +func TestIdenticalLinkAndUndo(t *testing.T) { + ev := setup(t, false) + rel := "bbno$/Bag or Die/edamame.flac" + h := ev.write(ev.host, rel, "same-bytes") + s := ev.write(ev.slave, rel, "same-bytes") + ev.scan() + + m := ev.match(rel) + if m.Status != store.StatusIdentical || m.Kind != store.KindPath { + t.Fatalf("want identical/path, got %s/%s", m.Status, m.Kind) + } + op, err := ev.e.Link(ctx, m.ID, LinkOptions{}) + if err != nil { + t.Fatal(err) + } + if op.QuarantinePath != "" { + t.Fatal("identical link must not quarantine") + } + if !sameInode(t, h, s) { + t.Fatal("slave is not a hardlink of host") + } + if ev.match(rel).Status != store.StatusLinked { + t.Fatal("match not marked linked") + } + + if err := ev.e.Undo(ctx, op.ID); err != nil { + t.Fatal(err) + } + if sameInode(t, h, s) || ev.read(s) != "same-bytes" { + t.Fatal("undo should leave an independent identical copy") + } +} + +func TestDifferentLinkQuarantinesAndUndoRestores(t *testing.T) { + ev := setup(t, false) + rel := "Artist/Album/song.flac" + h := ev.write(ev.host, rel, "host-version-longer") + s := ev.write(ev.slave, rel, "slave-version") + ev.scan() + + m := ev.match(rel) + if m.Status != store.StatusDifferent { + t.Fatalf("want different, got %s", m.Status) + } + op, err := ev.e.Link(ctx, m.ID, LinkOptions{}) + if err != nil { + t.Fatal(err) + } + if !sameInode(t, h, s) { + t.Fatal("not linked") + } + if op.QuarantinePath == "" || ev.read(op.QuarantinePath) != "slave-version" { + t.Fatal("original slave file not quarantined") + } + if err := ev.e.Undo(ctx, op.ID); err != nil { + t.Fatal(err) + } + if ev.read(s) != "slave-version" || ev.read(h) != "host-version-longer" { + t.Fatal("undo did not restore the original") + } + if _, err := os.Stat(op.QuarantinePath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("quarantine entry should be consumed by the restore") + } + if err := ev.e.Undo(ctx, op.ID); err == nil { + t.Fatal("double undo must fail") + } +} + +func TestSimilarExtensionLinkRenamesAndUndo(t *testing.T) { + ev := setup(t, false) + h := ev.write(ev.host, "A/B/01 - Song.flac", "flac-data") + s := ev.write(ev.slave, "A/B/01 - Song.mp3", "mp3") + ev.scan() + + m := ev.match("A/B/01 - Song.mp3") + if m.Status != store.StatusSimilar || m.Kind != store.KindExtension || m.RelHost != "A/B/01 - Song.flac" { + t.Fatalf("unexpected match %+v", m) + } + op, err := ev.e.Link(ctx, m.ID, LinkOptions{}) + if err != nil { + t.Fatal(err) + } + newSlave := filepath.Join(ev.slave, "A/B/01 - Song.flac") + if !sameInode(t, h, newSlave) { + t.Fatal("slave should now hold the host's .flac as a hardlink") + } + if _, err := os.Stat(s); !errors.Is(err, os.ErrNotExist) { + t.Fatal("old .mp3 should be moved to quarantine") + } + if err := ev.e.Undo(ctx, op.ID); err != nil { + t.Fatal(err) + } + if ev.read(s) != "mp3" { + t.Fatal(".mp3 not restored") + } + if _, err := os.Stat(newSlave); !errors.Is(err, os.ErrNotExist) { + t.Fatal("linked .flac should be removed on undo") + } + if ev.read(h) != "flac-data" { + t.Fatal("host must be untouched") + } +} + +func TestFuzzyTitleMatch(t *testing.T) { + ev := setup(t, false) + ev.write(ev.host, "A/B/03. My Song!.flac", "x") + ev.write(ev.slave, "A/B/My Song.mp3", "y") + ev.write(ev.slave, "A/B/My Song (Live).mp3", "z") + ev.write(ev.host, "A/Other Album/Lonely.flac", "x") + ev.write(ev.slave, "A/Single/Lonely.flac", "x") + ev.scan() + if m := ev.match("A/B/My Song.mp3"); m.Kind != store.KindFuzzy { + t.Fatalf("want fuzzy, got %s", m.Kind) + } + if m := ev.match("A/B/My Song (Live).mp3"); m.Status != store.StatusUnmatched { + t.Fatalf("live version must not match studio version, got %s", m.Status) + } + if m := ev.match("A/Single/Lonely.flac"); m.Kind != store.KindElsewhere || m.Status != store.StatusSimilar { + t.Fatalf("want elsewhere/similar, got %s/%s", m.Kind, m.Status) + } +} + +func TestDowngradeGuardAndPromote(t *testing.T) { + ev := setup(t, false) + rel := "A/B/song.flac" + h := ev.write(ev.host, rel, "lossy-host") + s := ev.write(ev.slave, rel, "lossless-slave!") + ev.scan() + m := ev.match(rel) + + // Simulate the probe verdict that the slave is better. + v, _ := json.Marshal(probe.Verdict{Better: "slave"}) + m.Verdict = v + if err := ev.st.UpsertMatch(ctx, &m); err != nil { + t.Fatal(err) + } + if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, ErrDowngrade) { + t.Fatalf("want ErrDowngrade, got %v", err) + } + if _, err := ev.e.Promote(ctx, m.ID, false); !errors.Is(err, ErrConfirm) { + t.Fatalf("promote without confirm must fail, got %v", err) + } + op, err := ev.e.Promote(ctx, m.ID, true) + if err != nil { + t.Fatal(err) + } + if !sameInode(t, h, s) || ev.read(h) != "lossless-slave!" { + t.Fatal("host should now be the slave's file") + } + if ev.read(op.QuarantinePath) != "lossy-host" { + t.Fatal("host original not quarantined") + } + if err := ev.e.Undo(ctx, op.ID); err != nil { + t.Fatal(err) + } + if ev.read(h) != "lossy-host" || ev.read(s) != "lossless-slave!" || sameInode(t, h, s) { + t.Fatal("promote undo failed") + } +} + +func TestChangedSinceScanIsRefused(t *testing.T) { + ev := setup(t, false) + rel := "A/B/c.flac" + ev.write(ev.host, rel, "aaa") + s := ev.write(ev.slave, rel, "bbb") + ev.scan() + m := ev.match(rel) + time.Sleep(10 * time.Millisecond) + if err := os.WriteFile(s, []byte("user edited this"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ev.e.Link(ctx, m.ID, LinkOptions{}); !errors.Is(err, fsops.ErrChanged) { + t.Fatalf("want ErrChanged, got %v", err) + } + if ev.read(s) != "user edited this" { + t.Fatal("file must be untouched") + } +} + +func TestDryRunChangesNothing(t *testing.T) { + ev := setup(t, true) + rel := "A/B/d.flac" + h := ev.write(ev.host, rel, "same") + s := ev.write(ev.slave, rel, "same") + ev.scan() + op, err := ev.e.Link(ctx, ev.match(rel).ID, LinkOptions{}) + if err != nil { + t.Fatal(err) + } + if !op.DryRun || op.Status != store.OpPlanned { + t.Fatalf("expected planned dry-run op, got %+v", op) + } + if sameInode(t, h, s) { + t.Fatal("dry run must not link") + } +} + +func TestAutoLinkRespectsSettleTime(t *testing.T) { + ev := setup(t, false) + ev.pair.AutoLinkIdentical = true + ev.pair.SettleMinutes = 60 + if err := ev.st.SavePair(ctx, &ev.pair); err != nil { + t.Fatal(err) + } + h := ev.write(ev.host, "A/B/e.flac", "same") + s := ev.write(ev.slave, "A/B/e.flac", "same") + ev.scan() + if sameInode(t, h, s) { + t.Fatal("freshly written files must not be auto-linked") + } + ev.pair.SettleMinutes = 0 + if err := ev.st.SavePair(ctx, &ev.pair); err != nil { + t.Fatal(err) + } + ev.scan() + if !sameInode(t, h, s) { + t.Fatal("settled identical file should be auto-linked") + } +} + +func TestBuildAndRemove(t *testing.T) { + ev := setup(t, false) + h1 := ev.write(ev.host, "bbno$/Album/one.flac", "1") + ev.write(ev.host, "bbno$/Album/two.flac", "2") + ev.write(ev.host, "bbno$/Album/cover.jpg", "img") + ev.write(ev.slave, "bbno$/Album/two.flac", "user's own different file") + + if _, err := ev.e.StartBuild(ev.pair.ID, []string{"bbno$"}); err != nil { + t.Fatal(err) + } + j := ev.wait() + if j.Results["linked"] != 1 || j.Results["skipped"] != 1 { + t.Fatalf("unexpected results %+v", j.Results) + } + s1 := filepath.Join(ev.slave, "bbno$/Album/one.flac") + if !sameInode(t, h1, s1) { + t.Fatal("build did not hardlink") + } + if ev.read(filepath.Join(ev.slave, "bbno$/Album/two.flac")) != "user's own different file" { + t.Fatal("build must never overwrite an existing slave file") + } + if _, err := os.Stat(filepath.Join(ev.slave, "bbno$/Album/cover.jpg")); err == nil { + t.Fatal("non-audio files must not be linked") + } + + if _, err := ev.e.StartRemove(ev.pair.ID, []string{"bbno$"}); err != nil { + t.Fatal(err) + } + j = ev.wait() + if j.Results["removed"] != 1 || j.Results["skipped"] != 1 { + t.Fatalf("unexpected remove results %+v", j.Results) + } + if _, err := os.Stat(s1); !errors.Is(err, os.ErrNotExist) { + t.Fatal("linked file should be removed") + } + if ev.read(h1) != "1" { + t.Fatal("host must be untouched") + } + if ev.read(filepath.Join(ev.slave, "bbno$/Album/two.flac")) != "user's own different file" { + t.Fatal("remove must never delete a non-linked file") + } +} + +func TestSymlinkEscapeIgnored(t *testing.T) { + ev := setup(t, false) + outside := t.TempDir() + os.WriteFile(filepath.Join(outside, "secret.flac"), []byte("x"), 0o644) + os.MkdirAll(filepath.Join(ev.slave, "Evil"), 0o755) + if err := os.Symlink(outside, filepath.Join(ev.slave, "Evil", "Album")); err != nil { + t.Fatal(err) + } + ev.write(ev.host, "Evil/Album/secret.flac", "x") + ev.scan() + if _, err := ev.st.MatchBySlave(ctx, ev.pair.ID, "Evil/Album/secret.flac"); err == nil { + t.Fatal("files behind a symlinked directory must not be scanned") + } + root, _ := fsops.NewRoot(ev.slave) + if _, err := root.Resolve("Evil/Album/secret.flac"); !errors.Is(err, fsops.ErrOutsideRoot) { + t.Fatalf("resolve through an escaping symlink must fail, got %v", err) + } + if _, err := root.Resolve("../Albums/x.flac"); !errors.Is(err, fsops.ErrOutsideRoot) { + t.Fatalf("dot-dot must fail, got %v", err) + } +} + +func TestPurgeAndOverlapValidation(t *testing.T) { + ev := setup(t, false) + rel := "A/B/f.flac" + ev.write(ev.host, rel, "one") + ev.write(ev.slave, rel, "two!") + ev.scan() + op, err := ev.e.Link(ctx, ev.match(rel).ID, LinkOptions{}) + if err != nil { + t.Fatal(err) + } + if err := ev.e.Purge(ctx, op.ID); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(op.QuarantinePath); !errors.Is(err, os.ErrNotExist) { + t.Fatal("purge did not delete quarantine") + } + if err := ev.e.Undo(ctx, op.ID); err == nil { + t.Fatal("undo after purge must fail") + } + + bad := store.Pair{Name: "bad", HostRoot: ev.host, SlaveRoot: filepath.Join(ev.host, "A")} + if _, err := ev.e.NormalizePair(&bad); err == nil { + t.Fatal("nested libraries must be rejected") + } + outside := store.Pair{Name: "bad", HostRoot: ev.host, SlaveRoot: t.TempDir()} + if _, err := ev.e.NormalizePair(&outside); err == nil { + t.Fatal("paths outside allowed roots must be rejected") + } +} + +func TestSameContent(t *testing.T) { + dir := t.TempDir() + big := bytes.Repeat([]byte("abcdefgh"), 300_000) // spans several chunks + a, b, c := filepath.Join(dir, "a"), filepath.Join(dir, "b"), filepath.Join(dir, "c") + os.WriteFile(a, big, 0o644) + os.WriteFile(b, big, 0o644) + alt := append([]byte(nil), big...) + alt[len(alt)-1] = 'X' + os.WriteFile(c, alt, 0o644) + if same, err := fsops.SameContent(a, b); err != nil || !same { + t.Fatalf("a,b should match: %v %v", same, err) + } + if same, _ := fsops.SameContent(a, c); same { + t.Fatal("a,c differ in the last byte") + } +} + +func TestNormTitle(t *testing.T) { + cases := map[string]string{ + "01 - Song.flac": "song", + "1-03 Song.mp3": "song", + "03. Song!.flac": "song", + "22.flac": "22", + "Song (Live).flac": "songlive", + "07 Song (Remix).flac": "songremix", + } + for in, want := range cases { + if got := normTitle(in); got != want { + t.Errorf("normTitle(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/engine/ops.go b/internal/engine/ops.go new file mode 100644 index 0000000..fd8ff48 --- /dev/null +++ b/internal/engine/ops.go @@ -0,0 +1,834 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/probe" + "github.com/cdnutter/playlink/internal/store" +) + +type LinkOptions struct { + // AllowDowngrade confirms linking when the host copy scored lower. + AllowDowngrade bool + Trigger string +} + +// snapshot holds the verified state of both files right before an operation. +type snapshot struct { + hostAbs, slaveAbs string + hi, si fsops.Info +} + +// preflight re-validates a match against the filesystem: both files must be +// plain regular files inside their roots, on one device, and unchanged since +// the scan recorded them. +func preflight(l *libs, m store.Match) (snapshot, error) { + var s snapshot + var err error + if s.hostAbs, err = l.host.Resolve(m.RelHost); err != nil { + return s, err + } + if s.slaveAbs, err = l.slave.Resolve(m.RelSlave); err != nil { + return s, err + } + if s.hi, err = fsops.RegularFile(s.hostAbs); err != nil { + return s, fmt.Errorf("host file: %w", err) + } + if s.si, err = fsops.RegularFile(s.slaveAbs); err != nil { + return s, fmt.Errorf("slave file: %w", err) + } + if s.hi.Ino != m.HostIno || !s.hi.Unchanged(m.HostSize, m.HostMtime) { + return s, fmt.Errorf("host %w", fsops.ErrChanged) + } + if s.si.Ino != m.SlaveIno || !s.si.Unchanged(m.SlaveSize, m.SlaveMtime) { + return s, fmt.Errorf("slave %w", fsops.ErrChanged) + } + if s.hi.Dev != s.si.Dev || s.hi.Dev != l.dev { + return s, fsops.ErrCrossDevice + } + if s.hi.SameInode(s.si) { + return s, errors.New("already linked") + } + return s, nil +} + +func quarantinePath(l *libs, opID int64, side, rel string) string { + return filepath.Join(l.quarantine, strconv.FormatInt(opID, 10), side, rel) +} + +// quarantine adds an extra hardlink of p (whose identity is in) inside the +// quarantine. The original stays in place; nothing is lost if a later step +// fails. The caller removes or replaces p afterwards. +func quarantine(p string, in fsops.Info, q string) error { + if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil { + return err + } + return fsops.LinkNew(p, in, q) +} + +// dropQuarantine removes a quarantine link that turned out not to be needed. +func dropQuarantine(l *libs, q string, keep fsops.Info) { + if err := fsops.RemoveLink(q, keep); err == nil { + pruneEmpty(filepath.Dir(q), l.quarantine) + } +} + +// removeOriginal deletes an original file after it was safely superseded. The +// inode must still be the one we verified. +func removeOriginal(p string, expect fsops.Info) error { + now, err := fsops.RegularFile(p) + if err != nil { + return err + } + if !now.SameInode(expect) || !now.Unchanged(expect.Size, expect.MtimeNs) { + return fmt.Errorf("%s %w", p, fsops.ErrChanged) + } + if err := os.Remove(p); err != nil { + return err + } + return fsops.SyncDir(filepath.Dir(p)) +} + +// pruneEmpty removes empty directories from dir upward, stopping at stop. +func pruneEmpty(dir, stop string) { + for dir != stop && fsops.Within(stop, dir) { + if err := os.Remove(dir); err != nil { + return + } + dir = filepath.Dir(dir) + } +} + +func (e *Engine) begin(ctx context.Context, o *store.Operation) error { + o.DryRun = e.st.DryRun(ctx) + if o.Trigger == "" { + o.Trigger = "manual" + } + o.Status = store.OpRunning + if o.DryRun { + o.Status = store.OpPlanned + } + return e.st.CreateOp(ctx, o) +} + +func (e *Engine) finish(ctx context.Context, o *store.Operation, err error) error { + if err != nil { + o.Status, o.Error = store.OpFailed, err.Error() + e.log.Warn("operation failed", "op", o.ID, "action", o.Action, "slave", o.RelSlave, "err", err) + } else { + o.Status = store.OpDone + e.log.Info("operation done", "op", o.ID, "action", o.Action, "slave", o.RelSlave, "host", o.RelHost) + } + if ferr := e.st.FinishOp(ctx, o.ID, o.Status, o.Error); ferr != nil && err == nil { + return ferr + } + return err +} + +func verdictOf(m store.Match) probe.Verdict { + var v probe.Verdict + _ = json.Unmarshal(m.Verdict, &v) + return v +} + +// Link replaces the slave file with a hardlink of the host file. +// +// Identical files are re-compared byte for byte right before linking and no +// quarantine copy is kept (the data exists in the host). Non-identical files +// are quarantined first so the operation can be undone. +func (e *Engine) Link(ctx context.Context, matchID int64, opt LinkOptions) (store.Operation, error) { + e.mu.Lock() + defer e.mu.Unlock() + + m, err := e.st.Match(ctx, matchID) + if err != nil { + return store.Operation{}, err + } + l, err := e.loadPair(ctx, m.PairID) + if err != nil { + return store.Operation{}, err + } + switch m.Status { + case store.StatusIdentical, store.StatusDifferent, store.StatusSimilar: + default: + return store.Operation{}, fmt.Errorf("cannot link a match with status %q", m.Status) + } + if m.Ignored { + return store.Operation{}, errors.New("match is ignored; un-ignore it first") + } + identical := m.Status == store.StatusIdentical + if !identical && verdictOf(m).Better == "slave" && !opt.AllowDowngrade { + return store.Operation{}, ErrDowngrade + } + s, err := preflight(l, m) + if err != nil { + return store.Operation{}, err + } + if identical { + same, err := fsops.SameContent(s.hostAbs, s.slaveAbs) + if err != nil { + return store.Operation{}, err + } + if !same { + e.refresh(ctx, l, m.RelSlave) + return store.Operation{}, errors.New("files are no longer identical; moved to manual review") + } + } + + newRel := filepath.Join(filepath.Dir(m.RelSlave), filepath.Base(m.RelHost)) + newAbs, err := l.slave.Resolve(newRel) + if err != nil { + return store.Operation{}, err + } + rename := newRel != m.RelSlave + if rename { + if _, err := os.Lstat(newAbs); err == nil { + return store.Operation{}, fmt.Errorf("slave already has %s: %w", newRel, fsops.ErrExists) + } + } + + o := store.Operation{PairID: l.pair.ID, Action: store.ActLink, RelHost: m.RelHost, RelSlave: m.RelSlave, + RelNew: newRel, Trigger: opt.Trigger} + if identical { + o.Bytes = s.si.Size // space freed + } else { + o.QuarantineSide = "slave" + } + if err := e.begin(ctx, &o); err != nil { + return o, err + } + if o.DryRun { + return o, nil + } + + err = func() error { + var q string + if !identical { + q = quarantinePath(l, o.ID, "slave", m.RelSlave) + if err := quarantine(s.slaveAbs, s.si, q); err != nil { + return fmt.Errorf("quarantine: %w", err) + } + o.QuarantinePath = q + if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil { + dropQuarantine(l, q, s.si) + return err + } + } + if !rename { + if err := fsops.ReplaceWithLink(s.hostAbs, s.hi, s.slaveAbs); err != nil { + if q != "" { + if cur, e2 := fsops.Lstat(s.slaveAbs); e2 == nil && cur.SameInode(s.si) { + dropQuarantine(l, q, s.si) // original untouched + e.clearQuarantine(ctx, o.ID) + } + } + return err + } + return nil + } + if err := fsops.LinkNew(s.hostAbs, s.hi, newAbs); err != nil { + if q != "" { + dropQuarantine(l, q, s.si) + e.clearQuarantine(ctx, o.ID) + } + return err + } + return removeOriginal(s.slaveAbs, s.si) + }() + err = e.finish(ctx, &o, err) + e.refresh(ctx, l, m.RelSlave) + if rename { + e.refresh(ctx, l, newRel) + } + return o, err +} + +func (e *Engine) clearQuarantine(ctx context.Context, opID int64) { + _, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = '' WHERE id = ?`, opID) +} + +// Promote is the override path: the slave copy is better, so it replaces the +// host file (whose original is quarantined) and both libraries share it. +func (e *Engine) Promote(ctx context.Context, matchID int64, confirmed bool) (store.Operation, error) { + if !confirmed { + return store.Operation{}, ErrConfirm + } + e.mu.Lock() + defer e.mu.Unlock() + + m, err := e.st.Match(ctx, matchID) + if err != nil { + return store.Operation{}, err + } + l, err := e.loadPair(ctx, m.PairID) + if err != nil { + return store.Operation{}, err + } + if m.Status != store.StatusDifferent && m.Status != store.StatusSimilar { + return store.Operation{}, fmt.Errorf("cannot promote a match with status %q", m.Status) + } + s, err := preflight(l, m) + if err != nil { + return store.Operation{}, err + } + newHostRel := filepath.Join(filepath.Dir(m.RelHost), filepath.Base(m.RelSlave)) + newHostAbs, err := l.host.Resolve(newHostRel) + if err != nil { + return store.Operation{}, err + } + rename := newHostRel != m.RelHost + if rename { + if _, err := os.Lstat(newHostAbs); err == nil { + return store.Operation{}, fmt.Errorf("host already has %s: %w", newHostRel, fsops.ErrExists) + } + } + + o := store.Operation{PairID: l.pair.ID, Action: store.ActPromote, RelHost: m.RelHost, RelSlave: m.RelSlave, + RelNew: newHostRel, QuarantineSide: "host", Trigger: "manual"} + if err := e.begin(ctx, &o); err != nil { + return o, err + } + if o.DryRun { + return o, nil + } + err = func() error { + q := quarantinePath(l, o.ID, "host", m.RelHost) + if err := quarantine(s.hostAbs, s.hi, q); err != nil { + return fmt.Errorf("quarantine: %w", err) + } + o.QuarantinePath = q + if _, err := e.st.DB.ExecContext(ctx, `UPDATE operations SET quarantine_path = ? WHERE id = ?`, q, o.ID); err != nil { + dropQuarantine(l, q, s.hi) + return err + } + if !rename { + if err := fsops.ReplaceWithLink(s.slaveAbs, s.si, s.hostAbs); err != nil { + if cur, e2 := fsops.Lstat(s.hostAbs); e2 == nil && cur.SameInode(s.hi) { + dropQuarantine(l, q, s.hi) + e.clearQuarantine(ctx, o.ID) + } + return err + } + return nil + } + if err := fsops.LinkNew(s.slaveAbs, s.si, newHostAbs); err != nil { + dropQuarantine(l, q, s.hi) + e.clearQuarantine(ctx, o.ID) + return err + } + return removeOriginal(s.hostAbs, s.hi) + }() + err = e.finish(ctx, &o, err) + e.refresh(ctx, l, m.RelSlave) + return o, err +} + +// ---- build (fresh slave library) and remove ---- + +// expand turns a list of relative files/folders into audio files under root. +func expand(ctx context.Context, l *libs, root fsops.Root, rels []string) ([]string, error) { + set := map[string]fsops.Info{} + for _, rel := range rels { + abs, err := root.Resolve(rel) + if err != nil { + return nil, err + } + fi, err := os.Lstat(abs) + if err != nil { + return nil, err + } + switch { + case fi.IsDir(): + sub, err := fsops.NewRoot(abs) + if err != nil { + return nil, err + } + files := map[string]fsops.Info{} + if _, err := walk(ctx, l, sub, files); err != nil { + return nil, err + } + for r, in := range files { + set[filepath.Join(rel, r)] = in + } + case fi.Mode().IsRegular() && l.hasExt(abs): + set[filepath.Clean(rel)] = fsops.Info{} + } + } + return sortedKeys(set), nil +} + +// StartBuild hardlinks the chosen host files/folders into the slave library, +// mirroring the host path. Existing slave files are never overwritten. +func (e *Engine) StartBuild(pairID int64, rels []string) (*Job, error) { + return e.startJob(pairID, "build", func(ctx context.Context, j *Job) error { + l, err := e.loadPair(ctx, pairID) + if err != nil { + return err + } + files, err := expand(ctx, l, l.host, rels) + if err != nil { + return err + } + j.phase("Linking into slave library", int64(len(files))) + for _, rel := range files { + if ctx.Err() != nil { + return ctx.Err() + } + e.buildOne(ctx, j, l, rel) + j.tick() + } + return nil + }) +} + +func (e *Engine) buildOne(ctx context.Context, j *Job, l *libs, rel string) { + e.mu.Lock() + defer e.mu.Unlock() + hostAbs, err := l.host.Resolve(rel) + if err != nil { + j.skip(rel + ": " + err.Error()) + return + } + slaveAbs, err := l.slave.Resolve(rel) + if err != nil { + j.skip(rel + ": " + err.Error()) + return + } + hi, err := fsops.RegularFile(hostAbs) + if err != nil { + j.skip(rel + ": " + err.Error()) + return + } + if si, err := fsops.Lstat(slaveAbs); err == nil { + if si.SameInode(hi) { + j.count("already linked") + } else { + j.skip(rel + ": a different file already exists in the slave library (scan, then use Review)") + } + return + } + o := store.Operation{PairID: l.pair.ID, Action: store.ActBuild, RelHost: rel, RelSlave: rel, RelNew: rel, Trigger: "build"} + if err := e.begin(ctx, &o); err != nil { + j.skip(rel + ": " + err.Error()) + return + } + if o.DryRun { + j.count("planned") + return + } + err = fsops.MkdirAllLike(filepath.Dir(slaveAbs)) + if err == nil { + err = fsops.LinkNew(hostAbs, hi, slaveAbs) + } + if err = e.finish(ctx, &o, err); err != nil { + j.skip(rel + ": " + err.Error()) + return + } + j.count("linked") + e.refresh(ctx, l, rel) +} + +// StartRemove deletes slave files that are hardlinks of the host file at the +// same path. Anything that is not such a link is left alone. +func (e *Engine) StartRemove(pairID int64, rels []string) (*Job, error) { + return e.startJob(pairID, "remove", func(ctx context.Context, j *Job) error { + l, err := e.loadPair(ctx, pairID) + if err != nil { + return err + } + files, err := expand(ctx, l, l.slave, rels) + if err != nil { + return err + } + j.phase("Removing links from slave library", int64(len(files))) + for _, rel := range files { + if ctx.Err() != nil { + return ctx.Err() + } + e.removeOne(ctx, j, l, rel) + j.tick() + } + return nil + }) +} + +func (e *Engine) removeOne(ctx context.Context, j *Job, l *libs, rel string) { + e.mu.Lock() + defer e.mu.Unlock() + hostAbs, err1 := l.host.Resolve(rel) + slaveAbs, err2 := l.slave.Resolve(rel) + if err1 != nil || err2 != nil { + j.skip(rel + ": invalid path") + return + } + hi, err := fsops.RegularFile(hostAbs) + if err != nil { + j.skip(rel + ": no host file at the same path, so it is not removed") + return + } + si, err := fsops.RegularFile(slaveAbs) + if err != nil { + j.skip(rel + ": " + err.Error()) + return + } + if !si.SameInode(hi) { + j.skip(rel + ": not a hardlink of the host file, so it is not removed") + return + } + o := store.Operation{PairID: l.pair.ID, Action: store.ActRemove, RelHost: rel, RelSlave: rel, Trigger: "build"} + if err := e.begin(ctx, &o); err != nil { + j.skip(rel + ": " + err.Error()) + return + } + if o.DryRun { + j.count("planned") + return + } + err = fsops.RemoveLink(slaveAbs, hi) + if err = e.finish(ctx, &o, err); err != nil { + j.skip(rel + ": " + err.Error()) + return + } + pruneEmpty(filepath.Dir(slaveAbs), l.slave.Path) + j.count("removed") + e.refresh(ctx, l, rel) +} + +// ---- undo, restore and purge ---- + +// Undo reverts a completed operation. Every branch verifies that the current +// file is still exactly what the operation left behind before touching it. +func (e *Engine) Undo(ctx context.Context, opID int64) error { + e.mu.Lock() + defer e.mu.Unlock() + o, err := e.st.Op(ctx, opID) + if err != nil { + return err + } + if o.Status != store.OpDone || o.UndoneAt != nil { + return errors.New("only completed operations that were not undone can be reverted") + } + if o.PurgedAt != nil { + return errors.New("the quarantined original was already purged") + } + l, err := e.loadPair(ctx, o.PairID) + if err != nil { + return err + } + res := func(r fsops.Root, rel string) string { + p, e := r.Resolve(rel) + if e != nil && err == nil { + err = e + } + return p + } + hostAbs := res(l.host, o.RelHost) + slaveAbs := res(l.slave, o.RelSlave) + if err != nil { + return err + } + + switch o.Action { + case store.ActBuild: + hi, err := fsops.RegularFile(hostAbs) + if err != nil { + return err + } + if err := fsops.RemoveLink(slaveAbs, hi); err != nil { + return err + } + pruneEmpty(filepath.Dir(slaveAbs), l.slave.Path) + + case store.ActRemove: + hi, err := fsops.RegularFile(hostAbs) + if err != nil { + return err + } + if err := fsops.MkdirAllLike(filepath.Dir(slaveAbs)); err != nil { + return err + } + if err := fsops.LinkNew(hostAbs, hi, slaveAbs); err != nil { + return err + } + + case store.ActLink: + newAbs := res(l.slave, o.RelNew) + if err != nil { + return err + } + hi, err := fsops.RegularFile(hostAbs) + if err != nil { + return err + } + cur, err := fsops.RegularFile(newAbs) + if err != nil { + return err + } + if !cur.SameInode(hi) { + return errors.New("the slave file is no longer linked to the host file; refusing to undo") + } + renamed := o.RelNew != o.RelSlave + if renamed { + if _, err := os.Lstat(slaveAbs); err == nil { + return fmt.Errorf("%s: %w", o.RelSlave, fsops.ErrExists) + } + } + if o.QuarantinePath == "" { + // Identical link: give the slave its own copy of the bytes again. + if err := fsops.CopyReplace(hostAbs, slaveAbs, hi.Mode); err != nil { + return err + } + } else { + qi, err := e.quarantined(l, o.QuarantinePath) + if err != nil { + return err + } + if renamed { + err = fsops.LinkNew(o.QuarantinePath, qi, slaveAbs) + } else { + err = fsops.ReplaceWithLink(o.QuarantinePath, qi, slaveAbs) + } + if err != nil { + return err + } + dropQuarantine(l, o.QuarantinePath, qi) + } + if renamed { + if err := fsops.RemoveLink(newAbs, hi); err != nil { + return err + } + } + e.refresh(ctx, l, o.RelNew) + + case store.ActPromote: + newHostAbs := res(l.host, o.RelNew) + if err != nil { + return err + } + si, err := fsops.RegularFile(slaveAbs) + if err != nil { + return err + } + cur, err := fsops.RegularFile(newHostAbs) + if err != nil { + return err + } + if !cur.SameInode(si) { + return errors.New("the host file changed after promotion; refusing to undo") + } + qi, err := e.quarantined(l, o.QuarantinePath) + if err != nil { + return err + } + if o.RelNew == o.RelHost { + err = fsops.ReplaceWithLink(o.QuarantinePath, qi, hostAbs) + } else { + if _, e2 := os.Lstat(hostAbs); e2 == nil { + return fmt.Errorf("%s: %w", o.RelHost, fsops.ErrExists) + } + if err = fsops.LinkNew(o.QuarantinePath, qi, hostAbs); err == nil { + err = fsops.RemoveLink(newHostAbs, si) + } + } + if err != nil { + return err + } + dropQuarantine(l, o.QuarantinePath, qi) + + default: + return fmt.Errorf("cannot undo %q", o.Action) + } + if err := e.st.MarkUndone(ctx, o.ID); err != nil { + return err + } + e.log.Info("operation undone", "op", o.ID, "action", o.Action) + e.refresh(ctx, l, o.RelSlave) + return nil +} + +// quarantined validates a quarantine path recorded in the database. +func (e *Engine) quarantined(l *libs, q string) (fsops.Info, error) { + q = filepath.Clean(q) + if !fsops.Within(l.quarantine, q) || q == l.quarantine { + return fsops.Info{}, fmt.Errorf("quarantine path %s: %w", q, fsops.ErrOutsideRoot) + } + return fsops.RegularFile(q) +} + +// Purge permanently deletes a quarantined original. +func (e *Engine) Purge(ctx context.Context, opID int64) error { + e.mu.Lock() + defer e.mu.Unlock() + o, err := e.st.Op(ctx, opID) + if err != nil { + return err + } + return e.purge(ctx, o) +} + +func (e *Engine) purge(ctx context.Context, o store.Operation) error { + if o.QuarantinePath == "" || o.PurgedAt != nil || o.UndoneAt != nil { + return errors.New("nothing to purge for this operation") + } + l, err := e.loadPair(ctx, o.PairID) + if err != nil { + return err + } + opDir := filepath.Join(l.quarantine, strconv.FormatInt(o.ID, 10)) + if !fsops.Within(l.quarantine, filepath.Clean(o.QuarantinePath)) || !fsops.Within(opDir, filepath.Clean(o.QuarantinePath)) { + return fsops.ErrOutsideRoot + } + if err := os.RemoveAll(opDir); err != nil { + return err + } + e.log.Info("quarantine purged", "op", o.ID, "path", o.QuarantinePath) + return e.st.MarkPurged(ctx, o.ID) +} + +// Maintenance purges expired quarantine entries and prunes sessions. +func (e *Engine) Maintenance(ctx context.Context) { + e.st.PruneSessions(ctx) + pairs, err := e.st.Pairs(ctx) + if err != nil { + return + } + for _, p := range pairs { + if p.RetentionDays <= 0 { + continue // keep forever + } + cutoff := time.Now().Add(-time.Duration(p.RetentionDays) * 24 * time.Hour).Unix() + ops, err := e.st.ExpiredQuarantine(ctx, p.ID, cutoff) + if err != nil { + continue + } + for _, o := range ops { + e.mu.Lock() + if err := e.purge(ctx, o); err != nil { + e.log.Warn("retention purge failed", "op", o.ID, "err", err) + } + e.mu.Unlock() + } + } +} + +// Recover runs at startup: operations left running by a crash are marked +// interrupted and their disposable staging files are removed. Originals are +// never at risk because every replacement is an atomic rename. +func (e *Engine) Recover(ctx context.Context) { + ops, err := e.st.RunningOps(ctx) + if err != nil { + return + } + for _, o := range ops { + if p, err := e.st.Pair(ctx, o.PairID); err == nil { + for _, root := range []string{p.SlaveRoot, p.HostRoot} { + for _, rel := range []string{o.RelSlave, o.RelNew, o.RelHost} { + if rel == "" { + continue + } + dir := filepath.Join(root, filepath.Dir(rel)) + ents, _ := os.ReadDir(dir) + for _, en := range ents { + if strings.HasPrefix(en.Name(), fsops.TmpPrefix) && en.Type().IsRegular() { + _ = os.Remove(filepath.Join(dir, en.Name())) + } + } + } + } + } + _ = e.st.FinishOp(ctx, o.ID, store.OpInterrupted, "interrupted by shutdown; rescan to verify state") + e.log.Warn("marked interrupted operation", "op", o.ID) + } +} + +// ---- browsing (build mode) ---- + +type TreeEntry struct { + Name string `json:"name"` + Rel string `json:"rel"` + Dir bool `json:"dir"` + Size int64 `json:"size,omitempty"` + State string `json:"state,omitempty"` // files: linked, conflict, missing + Linked int `json:"linked,omitempty"` +} + +// Tree lists a host directory and shows how much of it is in the slave. +func (e *Engine) Tree(ctx context.Context, pairID int64, rel string) ([]TreeEntry, error) { + l, err := e.loadPair(ctx, pairID) + if err != nil { + return nil, err + } + dir := l.host.Path + if rel != "" { + if dir, err = l.host.Resolve(rel); err != nil { + return nil, err + } + } + ents, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + linked, _ := e.st.LinkedHostPaths(ctx, pairID, rel) + perChild := map[string]int{} + for _, p := range linked { + sub := p + if rel != "" { + sub = strings.TrimPrefix(p, rel+string(filepath.Separator)) + } + if i := strings.IndexByte(sub, filepath.Separator); i > 0 { + perChild[sub[:i]]++ + } + } + out := []TreeEntry{} + for _, en := range ents { + name := en.Name() + if strings.HasPrefix(name, ".") || en.Type()&fs.ModeSymlink != 0 { + continue + } + child := name + if rel != "" { + child = filepath.Join(rel, name) + } + te := TreeEntry{Name: name, Rel: child, Dir: en.IsDir()} + if en.IsDir() { + te.Linked = perChild[name] + } else { + if !l.hasExt(name) { + continue + } + hi, err := fsops.Lstat(filepath.Join(dir, name)) + if err != nil { + continue + } + te.Size = hi.Size + te.State = "missing" + if sp, err := l.slave.Resolve(child); err == nil { + if si, err := fsops.Lstat(sp); err == nil { + te.State = "conflict" + if si.SameInode(hi) { + te.State = "linked" + } + } + } + } + out = append(out, te) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Dir != out[j].Dir { + return out[i].Dir + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out, nil +} diff --git a/internal/engine/scan.go b/internal/engine/scan.go new file mode 100644 index 0000000..9218bb2 --- /dev/null +++ b/internal/engine/scan.go @@ -0,0 +1,448 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + + "github.com/cdnutter/playlink/internal/fsops" + "github.com/cdnutter/playlink/internal/probe" + "github.com/cdnutter/playlink/internal/store" +) + +type walkStats struct { + files int + symlinks int + offDepth int // files not at Artist/Album/Title depth +} + +// walk lists audio files beneath root. Hidden entries, playlink staging files, +// the quarantine and symlinks are never followed or returned. +func walk(ctx context.Context, l *libs, root fsops.Root, out map[string]fsops.Info) (walkStats, error) { + var ws walkStats + err := filepath.WalkDir(root.Path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if p == root.Path { + return err + } + return nil // unreadable subtree: skip rather than abort + } + if ctx.Err() != nil { + return ctx.Err() + } + name := d.Name() + if p != root.Path && strings.HasPrefix(name, ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + if p == l.quarantine { + return filepath.SkipDir + } + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + ws.symlinks++ + return nil + } + if !d.Type().IsRegular() || !l.hasExt(name) { + return nil + } + in, err := fsops.Lstat(p) + if err != nil || !in.Mode.IsRegular() { + return nil + } + rel, err := root.Rel(p) + if err != nil { + return nil + } + out[rel] = in + ws.files++ + if strings.Count(rel, string(filepath.Separator)) != 2 { + ws.offDepth++ + } + return nil + }) + return ws, err +} + +var trackPrefix = regexp.MustCompile(`^\s*(?:\d{1,2}[-.]\s*)?\d{1,3}(?:\s*[-._)]\s*|\s+)`) + +// normTitle makes a conservative comparison key from a file name: the track +// number prefix is stripped and punctuation/case ignored. Parenthesised text +// is kept, so "Song (Live)" never matches "Song". +func normTitle(name string) string { + stem := strings.TrimSuffix(name, filepath.Ext(name)) + if s := trackPrefix.ReplaceAllString(stem, ""); strings.TrimSpace(s) != "" { + stem = s + } + var b strings.Builder + for _, r := range strings.ToLower(stem) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func firstComponent(rel string) string { + if i := strings.IndexByte(rel, filepath.Separator); i >= 0 { + return rel[:i] + } + return "" +} + +// hostIndex supports the fallback lookups for slave files without an exact +// path counterpart. +type hostIndex struct { + files map[string]fsops.Info + lower map[string]string // lower(rel) -> rel + byDir map[string][]string // lower(dir) -> rels + byTitle map[string][]string // lower(artist)|normTitle -> rels +} + +func buildIndex(files map[string]fsops.Info) *hostIndex { + ix := &hostIndex{files: files, lower: map[string]string{}, byDir: map[string][]string{}, byTitle: map[string][]string{}} + for _, rel := range sortedKeys(files) { + ix.lower[strings.ToLower(rel)] = rel + d := strings.ToLower(filepath.Dir(rel)) + ix.byDir[d] = append(ix.byDir[d], rel) + k := strings.ToLower(firstComponent(rel)) + "|" + normTitle(filepath.Base(rel)) + ix.byTitle[k] = append(ix.byTitle[k], rel) + } + return ix +} + +// losslessRank orders extension candidates so a lossless host file is +// preferred when several share the same title. +func extRank(rel string) int { + switch strings.ToLower(filepath.Ext(rel)) { + case ".flac", ".wav", ".aiff", ".aif", ".alac", ".ape", ".wv", ".dsf", ".dff": + return 0 + case ".m4a", ".opus", ".ogg": + return 1 + } + return 2 +} + +// find locates the host counterpart of a slave file. +func (ix *hostIndex) find(relSlave string) (string, string) { + if _, ok := ix.files[relSlave]; ok { + return relSlave, store.KindPath + } + if r, ok := ix.lower[strings.ToLower(relSlave)]; ok { + return r, store.KindPathCase + } + dir := strings.ToLower(filepath.Dir(relSlave)) + base := filepath.Base(relSlave) + stem := strings.ToLower(strings.TrimSuffix(base, filepath.Ext(base))) + var extHits, fuzzyHits []string + norm := normTitle(base) + for _, r := range ix.byDir[dir] { + b := filepath.Base(r) + if strings.ToLower(strings.TrimSuffix(b, filepath.Ext(b))) == stem { + extHits = append(extHits, r) + } else if norm != "" && normTitle(b) == norm { + fuzzyHits = append(fuzzyHits, r) + } + } + if len(extHits) > 0 { + best := extHits[0] + for _, r := range extHits[1:] { + if extRank(r) < extRank(best) { + best = r + } + } + return best, store.KindExtension + } + if len(fuzzyHits) == 1 { + return fuzzyHits[0], store.KindFuzzy + } + if artist := firstComponent(relSlave); artist != "" && norm != "" { + if hits := ix.byTitle[strings.ToLower(artist)+"|"+norm]; len(hits) == 1 { + return hits[0], store.KindElsewhere + } + } + return "", store.KindNone +} + +// classify fills in status, probes and verdict for a match, reusing cached +// results from prev when neither file changed. +func (e *Engine) classify(ctx context.Context, l *libs, m *store.Match, hi, si fsops.Info, prev *store.Match) { + m.SlaveIno, m.SlaveSize, m.SlaveMtime = si.Ino, si.Size, si.MtimeNs + m.HostIno, m.HostSize, m.HostMtime = 0, 0, 0 + m.HostProbe, m.SlaveProbe, m.Verdict, m.Note = nil, nil, nil, "" + if m.Kind == store.KindNone { + m.Status = store.StatusUnmatched + return + } + m.HostIno, m.HostSize, m.HostMtime = hi.Ino, hi.Size, hi.MtimeNs + + unchanged := prev != nil && prev.RelHost == m.RelHost && + prev.HostIno == hi.Ino && prev.HostSize == hi.Size && prev.HostMtime == hi.MtimeNs && + prev.SlaveIno == si.Ino && prev.SlaveSize == si.Size && prev.SlaveMtime == si.MtimeNs + if prev != nil && prev.RelHost == m.RelHost { + m.Ignored = prev.Ignored + } + + switch { + case hi.Dev != si.Dev: + m.Status, m.Note = store.StatusError, "files are on different filesystems" + return + case hi.SameInode(si): + m.Status = store.StatusLinked + return + } + + pathKind := m.Kind == store.KindPath || m.Kind == store.KindPathCase + if pathKind && hi.Size == si.Size { + if unchanged && (prev.Status == store.StatusIdentical || prev.Status == store.StatusDifferent) { + m.Status = prev.Status + } else { + hostAbs := filepath.Join(l.host.Path, m.RelHost) + slaveAbs := filepath.Join(l.slave.Path, m.RelSlave) + same, err := fsops.SameContent(hostAbs, slaveAbs) + switch { + case err != nil: + m.Status, m.Note = store.StatusError, "compare failed: "+err.Error() + return + case same: + m.Status = store.StatusIdentical + default: + m.Status = store.StatusDifferent + } + } + if m.Status == store.StatusIdentical { + return + } + } else if pathKind { + m.Status = store.StatusDifferent + } else { + m.Status = store.StatusSimilar + } + + // Manual review: gather codec details for both sides. + if unchanged && len(prev.HostProbe) > 0 && len(prev.SlaveProbe) > 0 { + m.HostProbe, m.SlaveProbe, m.Verdict = prev.HostProbe, prev.SlaveProbe, prev.Verdict + return + } + hp := e.prober.Probe(ctx, filepath.Join(l.host.Path, m.RelHost)) + sp := e.prober.Probe(ctx, filepath.Join(l.slave.Path, m.RelSlave)) + v := probe.Compare(hp, sp) + m.HostProbe, _ = json.Marshal(hp) + m.SlaveProbe, _ = json.Marshal(sp) + m.Verdict, _ = json.Marshal(v) +} + +// StartScan launches a background scan of a pair. +func (e *Engine) StartScan(pairID int64, trigger string) (*Job, error) { + return e.startJob(pairID, "scan", func(ctx context.Context, j *Job) error { + return e.scan(ctx, j, pairID, trigger) + }) +} + +func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) error { + started := time.Now() + l, err := e.loadPair(ctx, pairID) + if err != nil { + _ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), nil) + return err + } + warns := layoutWarnings(l) + + j.phase("Indexing host library", 0) + hostFiles := map[string]fsops.Info{} + hs, err := walk(ctx, l, l.host, hostFiles) + if err != nil { + _ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), warns) + return err + } + j.phase("Indexing slave library", 0) + slaveFiles := map[string]fsops.Info{} + ss, err := walk(ctx, l, l.slave, slaveFiles) + if err != nil { + _ = e.st.FinishScan(ctx, pairID, started.Unix(), err.Error(), warns) + return err + } + ix := buildIndex(hostFiles) + prev, err := e.st.AllMatches(ctx, pairID) + if err != nil { + return err + } + + j.phase("Comparing files", int64(len(slaveFiles))) + keep := map[string]bool{} + kinds := map[string]int{} + for _, rel := range sortedKeys(slaveFiles) { + if ctx.Err() != nil { + return ctx.Err() + } + si := slaveFiles[rel] + m := store.Match{PairID: pairID, RelSlave: rel} + m.RelHost, m.Kind = ix.find(rel) + var pm *store.Match + if p, ok := prev[rel]; ok { + pm = &p + } + e.classify(ctx, l, &m, hostFiles[m.RelHost], si, pm) + if err := e.st.UpsertMatch(ctx, &m); err != nil { + return err + } + keep[rel] = true + kinds[m.Kind]++ + j.count(m.Status) + j.tick() + } + if err := e.st.DeleteMatchesExcept(ctx, pairID, keep); err != nil { + return err + } + + // Layout warnings based on what was actually found. + if ss.files > 0 { + if n := kinds[store.KindElsewhere] + kinds[store.KindNone]; n*4 > ss.files { + warns = append(warns, fmt.Sprintf("%d of %d slave files have no counterpart at the same path in the host library. "+ + "Check that both libraries use the same Artist/Album/Title.ext layout.", n, ss.files)) + } + if ss.offDepth > 0 { + warns = append(warns, fmt.Sprintf("%d slave files are not at Artist/Album/Title.ext depth.", ss.offDepth)) + } + } + if hs.offDepth > 0 { + warns = append(warns, fmt.Sprintf("%d host files are not at Artist/Album/Title.ext depth.", hs.offDepth)) + } + if hs.symlinks+ss.symlinks > 0 { + warns = append(warns, fmt.Sprintf("Ignored %d symlinks (playlink never follows or replaces symlinks).", hs.symlinks+ss.symlinks)) + } + if e.prober.Bin == "" { + warns = append(warns, "ffprobe was not found, so codec and quality details are unavailable for manual review.") + } + if err := e.st.FinishScan(ctx, pairID, time.Now().Unix(), "", warns); err != nil { + return err + } + j.update(func(j *Job) { + j.Message = fmt.Sprintf("Scanned %d host and %d slave files in %s", hs.files, ss.files, time.Since(started).Round(time.Second)) + }) + + if l.pair.AutoLinkIdentical { + return e.autoLink(ctx, j, l, trigger) + } + return nil +} + +// autoLink links settled, byte-identical, same-path files after a scan. +func (e *Engine) autoLink(ctx context.Context, j *Job, l *libs, trigger string) error { + ids, err := e.st.IdenticalIDs(ctx, l.pair.ID) + if err != nil || len(ids) == 0 { + return err + } + if e.st.DryRun(ctx) { + j.update(func(j *Job) { + j.Message += fmt.Sprintf(". Dry run is on: %d identical files would be auto-linked.", len(ids)) + }) + return nil + } + return e.linkIDs(ctx, j, l.pair.ID, ids, "auto:"+trigger, true) +} + +// linkIDs links a batch of identical matches. +func (e *Engine) linkIDs(ctx context.Context, j *Job, pairID int64, ids []int64, trigger string, onlySettled bool) error { + j.phase("Linking identical files", int64(len(ids))) + settle := time.Duration(0) + if onlySettled { + if p, err := e.st.Pair(ctx, pairID); err == nil { + settle = time.Duration(p.SettleMinutes) * time.Minute + } + } + cutoff := time.Now().Add(-settle).UnixNano() + for _, id := range ids { + if ctx.Err() != nil { + return ctx.Err() + } + m, err := e.st.Match(ctx, id) + if err != nil { + j.tick() + continue + } + if onlySettled && m.Kind != store.KindPath { + j.skip(m.RelSlave + ": path differs by case, link manually") + j.tick() + continue + } + if settle > 0 && (m.SlaveMtime > cutoff || m.HostMtime > cutoff) { + j.skip(m.RelSlave + ": modified recently, waiting for it to settle") + j.tick() + continue + } + op, err := e.Link(ctx, id, LinkOptions{Trigger: trigger}) + switch { + case err != nil: + j.skip(m.RelSlave + ": " + err.Error()) + case op.DryRun: + j.count("planned") + default: + j.count("linked") + } + j.tick() + } + return nil +} + +// StartLinkIdentical links every identical match of a pair in the background. +func (e *Engine) StartLinkIdentical(pairID int64) (*Job, error) { + return e.startJob(pairID, "link-identical", func(ctx context.Context, j *Job) error { + ids, err := e.st.IdenticalIDs(ctx, pairID) + if err != nil { + return err + } + if len(ids) == 0 { + j.update(func(j *Job) { j.Message = "Nothing to link" }) + return nil + } + return e.linkIDs(ctx, j, pairID, ids, "bulk", false) + }) +} + +// refresh re-evaluates a single slave file after an operation. +func (e *Engine) refresh(ctx context.Context, l *libs, relSlave string) { + si, err := fsops.RegularFile(filepath.Join(l.slave.Path, relSlave)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + _ = e.st.DeleteMatch(ctx, l.pair.ID, relSlave) + } + return + } + m := store.Match{PairID: l.pair.ID, RelSlave: relSlave} + var pm *store.Match + if p, err := e.st.MatchBySlave(ctx, l.pair.ID, relSlave); err == nil { + pm = &p + } + var hi fsops.Info + if hp, err := l.host.Resolve(relSlave); err == nil { + if in, err := fsops.RegularFile(hp); err == nil { + hi = in + m.RelHost, m.Kind = relSlave, store.KindPath + } + } + if m.Kind == "" { + if pm != nil && pm.RelHost != "" { + if in, err := fsops.RegularFile(filepath.Join(l.host.Path, pm.RelHost)); err == nil { + hi = in + m.RelHost, m.Kind = pm.RelHost, pm.Kind + } + } + } + if m.Kind == "" { + m.Kind = store.KindNone + } + e.classify(ctx, l, &m, hi, si, pm) + _ = e.st.UpsertMatch(ctx, &m) +} diff --git a/internal/fsops/fsops.go b/internal/fsops/fsops.go new file mode 100644 index 0000000..153d7b9 --- /dev/null +++ b/internal/fsops/fsops.go @@ -0,0 +1,405 @@ +// Package fsops contains the low-level, safety-critical filesystem primitives +// used by playlink: confined path resolution, byte comparison, atomic +// hardlink replacement and directory fsyncs. +// +// Every mutating primitive in this package follows the same rule: a path that +// holds user data is never left missing. Replacements are staged as a +// temporary hardlink in the destination directory and then atomically renamed +// over the target. +package fsops + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" +) + +// TmpPrefix marks staging files created by playlink. Anything carrying this +// prefix is disposable: it is either a hardlink to a host file or a copy that +// has not been renamed into place yet. +const TmpPrefix = ".playlink-tmp-" + +var ( + ErrOutsideRoot = errors.New("path escapes library root") + ErrNotRegular = errors.New("not a regular file") + ErrCrossDevice = errors.New("files are on different filesystems; hardlinks are impossible") + ErrChanged = errors.New("file changed since it was scanned; rescan first") + ErrExists = errors.New("destination already exists") + ErrVerifyFailed = errors.New("post-operation verification failed") +) + +// Info is the subset of stat(2) that playlink cares about. +type Info struct { + Dev uint64 `json:"dev"` + Ino uint64 `json:"ino"` + Nlink uint64 `json:"nlink"` + Size int64 `json:"size"` + MtimeNs int64 `json:"mtime_ns"` + Mode fs.FileMode `json:"-"` +} + +// SameInode reports whether a and b are the same file on disk. +func (i Info) SameInode(o Info) bool { return i.Dev == o.Dev && i.Ino == o.Ino } + +// Unchanged reports whether size and mtime still match a previous snapshot. +func (i Info) Unchanged(size, mtimeNs int64) bool { return i.Size == size && i.MtimeNs == mtimeNs } + +// Lstat stats p without following a trailing symlink. +func Lstat(p string) (Info, error) { + fi, err := os.Lstat(p) + if err != nil { + return Info{}, err + } + return infoFrom(fi), nil +} + +func infoFrom(fi fs.FileInfo) Info { + in := Info{Size: fi.Size(), MtimeNs: fi.ModTime().UnixNano(), Mode: fi.Mode()} + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + in.Dev = uint64(st.Dev) + in.Ino = uint64(st.Ino) + in.Nlink = uint64(st.Nlink) + } + return in +} + +// RegularFile lstat's p and fails unless it is a plain regular file (not a +// symlink, device, directory, ...). +func RegularFile(p string) (Info, error) { + in, err := Lstat(p) + if err != nil { + return in, err + } + if !in.Mode.IsRegular() { + return in, fmt.Errorf("%s: %w", p, ErrNotRegular) + } + return in, nil +} + +// Root is an absolute, symlink-free directory that confines every path +// playlink touches for a library. +type Root struct { + Path string +} + +// NewRoot validates dir and resolves symlinks so containment checks are exact. +func NewRoot(dir string) (Root, error) { + if !filepath.IsAbs(dir) { + return Root{}, fmt.Errorf("%q is not an absolute path", dir) + } + real, err := filepath.EvalSymlinks(filepath.Clean(dir)) + if err != nil { + return Root{}, err + } + fi, err := os.Stat(real) + if err != nil { + return Root{}, err + } + if !fi.IsDir() { + return Root{}, fmt.Errorf("%q is not a directory", dir) + } + return Root{Path: real}, nil +} + +// Within reports whether p equals base or lies beneath it. Both must be clean. +func Within(base, p string) bool { + if p == base { + return true + } + if base == "/" { + return strings.HasPrefix(p, "/") + } + return strings.HasPrefix(p, base+string(filepath.Separator)) +} + +// CleanRel validates a library-relative path coming from the database or an +// API request. +func CleanRel(rel string) (string, error) { + if rel == "" || filepath.IsAbs(rel) || strings.ContainsRune(rel, 0) { + return "", fmt.Errorf("invalid relative path %q", rel) + } + c := filepath.Clean(rel) + if c == "." || c == ".." || strings.HasPrefix(c, "../") { + return "", fmt.Errorf("%q: %w", rel, ErrOutsideRoot) + } + return c, nil +} + +// Resolve maps a relative path to an absolute one inside the root. The +// deepest existing ancestor is resolved through symlinks and must still be +// inside the root, so a symlinked directory can never redirect a write +// outside the library. The final component itself is not followed. +func (r Root) Resolve(rel string) (string, error) { + c, err := CleanRel(rel) + if err != nil { + return "", err + } + full := filepath.Join(r.Path, c) + anc := filepath.Dir(full) + for { + real, err := filepath.EvalSymlinks(anc) + if err == nil { + if !Within(r.Path, real) { + return "", fmt.Errorf("%q: %w", rel, ErrOutsideRoot) + } + // Re-anchor the missing tail on the resolved ancestor. + tail, _ := filepath.Rel(anc, full) + return filepath.Join(real, tail), nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + if anc == r.Path || anc == "/" { + return "", err + } + anc = filepath.Dir(anc) + } +} + +// Rel converts an absolute path inside the root back to a relative one. +func (r Root) Rel(abs string) (string, error) { + if !Within(r.Path, abs) || abs == r.Path { + return "", ErrOutsideRoot + } + return filepath.Rel(r.Path, abs) +} + +// SameContent compares two files byte for byte (the equivalent of cmp(1)). +// It refuses to report equality if either file changed while being read. +func SameContent(a, b string) (bool, error) { + fa, err := os.Open(a) + if err != nil { + return false, err + } + defer fa.Close() + fb, err := os.Open(b) + if err != nil { + return false, err + } + defer fb.Close() + + sa, err := fa.Stat() + if err != nil { + return false, err + } + sb, err := fb.Stat() + if err != nil { + return false, err + } + if !sa.Mode().IsRegular() || !sb.Mode().IsRegular() { + return false, ErrNotRegular + } + if sa.Size() != sb.Size() { + return false, nil + } + ia, ib := infoFrom(sa), infoFrom(sb) + if ia.SameInode(ib) { + return true, nil + } + + const chunk = 1 << 20 + bufA := make([]byte, chunk) + bufB := make([]byte, chunk) + for { + na, errA := io.ReadFull(fa, bufA) + nb, errB := io.ReadFull(fb, bufB) + if na != nb || !bytes.Equal(bufA[:na], bufB[:nb]) { + return false, nil + } + endA := errA == io.EOF || errA == io.ErrUnexpectedEOF + endB := errB == io.EOF || errB == io.ErrUnexpectedEOF + if errA != nil && !endA { + return false, errA + } + if errB != nil && !endB { + return false, errB + } + if endA || endB { + if endA != endB { + return false, nil + } + break + } + } + + // Guard against a writer modifying either file mid-compare. + for _, pair := range []struct { + p string + prev Info + }{{a, ia}, {b, ib}} { + now, err := Lstat(pair.p) + if err != nil { + return false, err + } + if !now.SameInode(pair.prev) || !now.Unchanged(pair.prev.Size, pair.prev.MtimeNs) { + return false, ErrChanged + } + } + return true, nil +} + +// SyncDir fsyncs a directory so renames and new links inside it are durable. +func SyncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + if err := d.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) { + return err + } + return nil +} + +func tmpName(dir string) (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return filepath.Join(dir, TmpPrefix+hex.EncodeToString(b[:])), nil +} + +// StageLink creates a temporary hardlink to src inside dstDir and verifies it +// points at the same inode. The caller must rename or remove the result. +func StageLink(src string, srcInfo Info, dstDir string) (string, error) { + tmp, err := tmpName(dstDir) + if err != nil { + return "", err + } + if err := os.Link(src, tmp); err != nil { + if errors.Is(err, syscall.EXDEV) { + return "", ErrCrossDevice + } + return "", err + } + got, err := Lstat(tmp) + if err != nil || !got.SameInode(srcInfo) { + _ = os.Remove(tmp) + if err == nil { + err = fmt.Errorf("%w: staged link does not share the source inode", ErrVerifyFailed) + } + return "", err + } + return tmp, nil +} + +// LinkNew hardlinks src to dst, which must not exist. Verified by inode. +func LinkNew(src string, srcInfo Info, dst string) error { + if _, err := os.Lstat(dst); err == nil { + return fmt.Errorf("%s: %w", dst, ErrExists) + } + if err := os.Link(src, dst); err != nil { + if errors.Is(err, syscall.EXDEV) { + return ErrCrossDevice + } + return err + } + got, err := Lstat(dst) + if err != nil || !got.SameInode(srcInfo) { + // dst was created by us a moment ago and is only an extra link. + _ = os.Remove(dst) + if err == nil { + err = fmt.Errorf("%w: %s does not share the source inode", ErrVerifyFailed, dst) + } + return err + } + return SyncDir(filepath.Dir(dst)) +} + +// ReplaceWithLink atomically replaces dst with a hardlink to src. dst must be +// a regular file on the same device. The staged link is renamed over dst, so +// dst never disappears, even on a crash. +func ReplaceWithLink(src string, srcInfo Info, dst string) error { + tmp, err := StageLink(src, srcInfo, filepath.Dir(dst)) + if err != nil { + return err + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + if err := SyncDir(filepath.Dir(dst)); err != nil { + return err + } + got, err := Lstat(dst) + if err != nil { + return err + } + if !got.SameInode(srcInfo) { + return fmt.Errorf("%w: %s does not share the source inode after rename", ErrVerifyFailed, dst) + } + return nil +} + +// RemoveLink deletes p only if it is a hardlink of keep (same inode and at +// least one other link). This is the only way playlink deletes a file outside +// the quarantine, and it never destroys the last copy of any data. +func RemoveLink(p string, keep Info) error { + in, err := RegularFile(p) + if err != nil { + return err + } + if !in.SameInode(keep) || in.Nlink < 2 { + return fmt.Errorf("refusing to remove %s: it is not a hardlink of the file being kept", p) + } + if err := os.Remove(p); err != nil { + return err + } + return SyncDir(filepath.Dir(p)) +} + +// CopyReplace replaces dst with an independent byte copy of src (used to undo +// a link between identical files). The copy is fsynced and verified before +// being renamed into place. +func CopyReplace(src, dst string, mode fs.FileMode) error { + tmp, err := tmpName(filepath.Dir(dst)) + if err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode.Perm()) + if err != nil { + return err + } + cleanup := func(e error) error { out.Close(); _ = os.Remove(tmp); return e } + if _, err := io.Copy(out, in); err != nil { + return cleanup(err) + } + if err := out.Sync(); err != nil { + return cleanup(err) + } + if err := out.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + same, err := SameContent(src, tmp) + if err != nil || !same { + _ = os.Remove(tmp) + if err == nil { + err = fmt.Errorf("%w: copy differs from source", ErrVerifyFailed) + } + return err + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + return SyncDir(filepath.Dir(dst)) +} + +// MkdirAllLike creates dir and any missing parents with 0775 permissions. +func MkdirAllLike(dir string) error { + return os.MkdirAll(dir, 0o775) +} diff --git a/internal/probe/probe.go b/internal/probe/probe.go new file mode 100644 index 0000000..e7727a0 --- /dev/null +++ b/internal/probe/probe.go @@ -0,0 +1,219 @@ +// Package probe extracts audio stream details with ffprobe and ranks them. +package probe + +import ( + "context" + "encoding/json" + "fmt" + "math" + "os/exec" + "strconv" + "strings" + "time" +) + +// Info describes the primary audio stream of a file. +type Info struct { + Codec string `json:"codec"` + CodecLong string `json:"codec_long,omitempty"` + Format string `json:"format,omitempty"` + Lossless bool `json:"lossless"` + Bitrate int64 `json:"bitrate"` // bits per second + SampleRate int `json:"sample_rate"` + BitDepth int `json:"bit_depth,omitempty"` + Channels int `json:"channels"` + Duration float64 `json:"duration"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + Error string `json:"error,omitempty"` +} + +// Prober runs ffprobe. An empty Bin disables probing. +type Prober struct { + Bin string + Timeout time.Duration +} + +type ffOut struct { + Streams []struct { + CodecName string `json:"codec_name"` + CodecLongName string `json:"codec_long_name"` + SampleRate string `json:"sample_rate"` + Channels int `json:"channels"` + BitsPerRawSample string `json:"bits_per_raw_sample"` + BitsPerSample int `json:"bits_per_sample"` + SampleFmt string `json:"sample_fmt"` + BitRate string `json:"bit_rate"` + Duration string `json:"duration"` + } `json:"streams"` + Format struct { + FormatName string `json:"format_name"` + Duration string `json:"duration"` + BitRate string `json:"bit_rate"` + Tags map[string]string `json:"tags"` + } `json:"format"` +} + +// Probe inspects path. Failures are reported in Info.Error rather than as an +// error so a single unreadable file never aborts a scan. +func (p Prober) Probe(ctx context.Context, path string) Info { + if p.Bin == "" { + return Info{Error: "ffprobe not available"} + } + to := p.Timeout + if to == 0 { + to = 30 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, to) + defer cancel() + out, err := exec.CommandContext(ctx, p.Bin, "-v", "error", "-print_format", "json", + "-show_format", "-show_streams", "-select_streams", "a:0", "--", path).Output() + if err != nil { + return Info{Error: fmt.Sprintf("ffprobe: %v", err)} + } + var ff ffOut + if err := json.Unmarshal(out, &ff); err != nil { + return Info{Error: fmt.Sprintf("ffprobe output: %v", err)} + } + if len(ff.Streams) == 0 { + return Info{Error: "no audio stream"} + } + s := ff.Streams[0] + in := Info{ + Codec: s.CodecName, + CodecLong: s.CodecLongName, + Format: ff.Format.FormatName, + Channels: s.Channels, + Lossless: IsLossless(s.CodecName), + } + in.SampleRate, _ = strconv.Atoi(s.SampleRate) + in.BitDepth, _ = strconv.Atoi(s.BitsPerRawSample) + if in.BitDepth == 0 { + in.BitDepth = s.BitsPerSample + } + if in.BitDepth == 0 && in.Lossless { + in.BitDepth = depthFromSampleFmt(s.SampleFmt) + } + in.Bitrate, _ = strconv.ParseInt(s.BitRate, 10, 64) + if in.Bitrate == 0 { + in.Bitrate, _ = strconv.ParseInt(ff.Format.BitRate, 10, 64) + } + in.Duration, _ = strconv.ParseFloat(ff.Format.Duration, 64) + if in.Duration == 0 { + in.Duration, _ = strconv.ParseFloat(s.Duration, 64) + } + for k, v := range ff.Format.Tags { + switch strings.ToLower(k) { + case "title": + in.Title = v + case "artist": + in.Artist = v + case "album": + in.Album = v + } + } + return in +} + +func depthFromSampleFmt(f string) int { + switch strings.TrimSuffix(f, "p") { + case "s16": + return 16 + case "s32", "flt": + return 24 // FLAC/ALAC decode 24-bit into s32 + case "u8": + return 8 + } + return 0 +} + +// IsLossless reports whether an ffprobe codec name is a lossless codec. +func IsLossless(codec string) bool { + switch { + case codec == "flac", codec == "alac", codec == "ape", codec == "wavpack", + codec == "tta", codec == "mlp", codec == "truehd", codec == "wmalossless", + strings.HasPrefix(codec, "pcm_"), strings.HasPrefix(codec, "dsd_"): + return true + } + return false +} + +// codecEfficiency scales a lossy bitrate into "MP3-equivalent" kbps so a 160k +// Opus file is not ranked below a 192k MP3. +func codecEfficiency(codec string) float64 { + switch codec { + case "opus": + return 1.6 + case "aac", "aac_latm": + return 1.3 + case "vorbis": + return 1.3 + case "mp3", "mp3float": + return 1.0 + case "wmav2", "wmav1": + return 0.9 + } + return 1.0 +} + +// Score returns a comparable quality number. Lossless always beats lossy. +func Score(in Info) float64 { + if in.Error != "" || in.Codec == "" { + return -1 + } + if in.Lossless { + depth := in.BitDepth + if depth == 0 { + depth = 16 + } + return 1e6 + float64(depth)*1e3 + float64(in.SampleRate)/1e3 + } + return float64(in.Bitrate) / 1000 * codecEfficiency(in.Codec) +} + +// Verdict compares host and slave quality. +type Verdict struct { + // Better is "host", "slave", "equal" or "unknown". + Better string `json:"better"` + // DurationDelta is |host-slave| in seconds; large deltas suggest a + // different edit or version of the song rather than a re-encode. + DurationDelta float64 `json:"duration_delta"` + Notes []string `json:"notes"` +} + +// Compare ranks host against slave. Within 5% is treated as equal. +func Compare(host, slave Info) Verdict { + v := Verdict{Better: "unknown"} + if host.Error != "" || slave.Error != "" { + v.Notes = append(v.Notes, "could not probe one of the files") + return v + } + if host.Duration > 0 && slave.Duration > 0 { + v.DurationDelta = math.Abs(host.Duration - slave.Duration) + if v.DurationDelta > 2 { + v.Notes = append(v.Notes, fmt.Sprintf("durations differ by %.1fs: possibly a different version or edit", v.DurationDelta)) + } + } + hs, ss := Score(host), Score(slave) + switch { + case hs <= 0 || ss <= 0: + case math.Abs(hs-ss) <= 0.05*math.Max(hs, ss): + v.Better = "equal" + case hs > ss: + v.Better = "host" + default: + v.Better = "slave" + } + if host.Lossless != slave.Lossless { + if host.Lossless { + v.Notes = append(v.Notes, "host is lossless, slave is lossy") + } else { + v.Notes = append(v.Notes, "slave is lossless but host is lossy: linking would downgrade the slave") + } + } + if host.Channels != slave.Channels && host.Channels > 0 && slave.Channels > 0 { + v.Notes = append(v.Notes, fmt.Sprintf("channel count differs (%d vs %d)", host.Channels, slave.Channels)) + } + return v +} diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go new file mode 100644 index 0000000..1c56cc7 --- /dev/null +++ b/internal/probe/probe_test.go @@ -0,0 +1,57 @@ +package probe + +import ( + "context" + "os/exec" + "path/filepath" + "testing" +) + +func TestScoreOrdering(t *testing.T) { + flac24 := Info{Codec: "flac", Lossless: true, BitDepth: 24, SampleRate: 96000} + flac16 := Info{Codec: "flac", Lossless: true, BitDepth: 16, SampleRate: 44100} + opus160 := Info{Codec: "opus", Bitrate: 160_000} + mp3320 := Info{Codec: "mp3", Bitrate: 320_000} + mp3128 := Info{Codec: "mp3", Bitrate: 128_000} + order := []Info{flac24, flac16, mp3320, opus160, mp3128} + for i := 1; i < len(order); i++ { + if Score(order[i-1]) <= Score(order[i]) { + t.Errorf("%v should outrank %v", order[i-1], order[i]) + } + } + if v := Compare(mp3128, flac16); v.Better != "slave" { + t.Errorf("lossless slave should win, got %s", v.Better) + } + if v := Compare(mp3320, Info{Codec: "mp3", Bitrate: 310_000}); v.Better != "equal" { + t.Errorf("within 5%% should be equal, got %s", v.Better) + } +} + +func TestProbeRealFiles(t *testing.T) { + ffmpeg, err1 := exec.LookPath("ffmpeg") + ffprobe, err2 := exec.LookPath("ffprobe") + if err1 != nil || err2 != nil { + t.Skip("ffmpeg/ffprobe not installed") + } + dir := t.TempDir() + flac, mp3 := filepath.Join(dir, "a.flac"), filepath.Join(dir, "a.mp3") + for _, args := range [][]string{ + {"-f", "lavfi", "-i", "sine=f=440:d=2", "-sample_fmt", "s16", flac}, + {"-f", "lavfi", "-i", "sine=f=440:d=2", "-b:a", "128k", mp3}, + } { + if out, err := exec.Command(ffmpeg, append([]string{"-v", "error", "-y"}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("ffmpeg: %v %s", err, out) + } + } + p := Prober{Bin: ffprobe} + fi, mi := p.Probe(context.Background(), flac), p.Probe(context.Background(), mp3) + if fi.Error != "" || mi.Error != "" { + t.Fatalf("probe errors: %q %q", fi.Error, mi.Error) + } + if !fi.Lossless || fi.BitDepth != 16 || mi.Lossless || mi.Codec != "mp3" { + t.Fatalf("unexpected probe results %+v / %+v", fi, mi) + } + if v := Compare(mi, fi); v.Better != "slave" { + t.Fatalf("flac slave should beat mp3 host, got %+v", v) + } +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..ffd65c3 --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,651 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" +) + +var ErrNotFound = errors.New("not found") + +// ---- users & sessions ---- + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` +} + +func (s *Store) UserCount(ctx context.Context) (int, error) { + var n int + err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +// CreateFirstUser inserts a user only if none exist (atomic setup). +func (s *Store) CreateFirstUser(ctx context.Context, username, hash string) error { + res, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, created_at) + SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM users)`, username, hash, now()) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return errors.New("setup already completed") + } + return nil +} + +func (s *Store) UserByName(ctx context.Context, name string) (User, error) { + var u User + err := s.DB.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE username = ?`, name). + Scan(&u.ID, &u.Username, &u.PasswordHash) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + return u, err +} + +func (s *Store) UserByID(ctx context.Context, id int64) (User, error) { + var u User + err := s.DB.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE id = ?`, id). + Scan(&u.ID, &u.Username, &u.PasswordHash) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + return u, err +} + +func (s *Store) SetPassword(ctx context.Context, id int64, hash string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) + return err +} + +func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID, expires int64) error { + _, err := s.DB.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, + tokenHash, userID, expires, now()) + return err +} + +func (s *Store) SessionUser(ctx context.Context, tokenHash string) (int64, error) { + var uid int64 + err := s.DB.QueryRowContext(ctx, `SELECT user_id FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now()).Scan(&uid) + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } + return uid, err +} + +func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash) + return err +} + +func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, except string) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, except) + return err +} + +func (s *Store) PruneSessions(ctx context.Context) { + _, _ = s.DB.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, now()) +} + +// ---- pairs ---- + +type Pair struct { + ID int64 `json:"id"` + Name string `json:"name"` + HostRoot string `json:"host_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"` + LastScanAt *int64 `json:"last_scan_at"` + LastScanError string `json:"last_scan_error"` + LastScanWarnings []string `json:"last_scan_warnings"` + CreatedAt int64 `json:"created_at"` +} + +const pairCols = `id, name, host_root, slave_root, quarantine_dir, extensions, auto_link_identical, + scan_interval_minutes, retention_days, settle_minutes, last_scan_at, last_scan_error, last_scan_warnings, created_at` + +type scanner interface{ Scan(...any) error } + +func scanPair(r scanner) (Pair, error) { + var p Pair + var exts, warns string + var last sql.NullInt64 + err := r.Scan(&p.ID, &p.Name, &p.HostRoot, &p.SlaveRoot, &p.QuarantineDir, &exts, &p.AutoLinkIdentical, + &p.ScanIntervalMinutes, &p.RetentionDays, &p.SettleMinutes, &last, &p.LastScanError, &warns, &p.CreatedAt) + if err != nil { + return p, err + } + if last.Valid { + p.LastScanAt = &last.Int64 + } + p.Extensions = strings.Split(exts, ",") + _ = json.Unmarshal([]byte(warns), &p.LastScanWarnings) + if p.LastScanWarnings == nil { + p.LastScanWarnings = []string{} + } + return p, nil +} + +func (s *Store) Pairs(ctx context.Context) ([]Pair, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+pairCols+` FROM pairs ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Pair{} + for rows.Next() { + p, err := scanPair(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) Pair(ctx context.Context, id int64) (Pair, error) { + p, err := scanPair(s.DB.QueryRowContext(ctx, `SELECT `+pairCols+` FROM pairs WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + return p, err +} + +func (s *Store) SavePair(ctx context.Context, p *Pair) error { + exts := strings.Join(p.Extensions, ",") + if p.ID == 0 { + res, err := s.DB.ExecContext(ctx, `INSERT INTO pairs (name, host_root, slave_root, quarantine_dir, extensions, + auto_link_identical, scan_interval_minutes, retention_days, settle_minutes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.Name, p.HostRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, + p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, now()) + if err != nil { + return err + } + p.ID, _ = res.LastInsertId() + return nil + } + _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET name = ?, host_root = ?, slave_root = ?, quarantine_dir = ?, + extensions = ?, auto_link_identical = ?, scan_interval_minutes = ?, retention_days = ?, settle_minutes = ? + WHERE id = ?`, + p.Name, p.HostRoot, p.SlaveRoot, p.QuarantineDir, exts, p.AutoLinkIdentical, + p.ScanIntervalMinutes, p.RetentionDays, p.SettleMinutes, p.ID) + return err +} + +func (s *Store) DeletePair(ctx context.Context, id int64) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM pairs WHERE id = ?`, id) + return err +} + +func (s *Store) ClearMatches(ctx context.Context, pairID int64) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM matches WHERE pair_id = ?`, pairID) + return err +} + +func (s *Store) FinishScan(ctx context.Context, pairID, at int64, scanErr string, warnings []string) error { + if warnings == nil { + warnings = []string{} + } + w, _ := json.Marshal(warnings) + _, err := s.DB.ExecContext(ctx, `UPDATE pairs SET last_scan_at = ?, last_scan_error = ?, last_scan_warnings = ? WHERE id = ?`, + at, scanErr, string(w), pairID) + return err +} + +// ---- matches ---- + +// Match statuses. +const ( + StatusLinked = "linked" // already the same inode + StatusIdentical = "identical" // byte-identical, safe to auto-link + StatusDifferent = "different" // same path, different content: manual review + StatusSimilar = "similar" // similar name (other extension / normalized title): manual review + StatusUnmatched = "unmatched" // exists only in the slave library + StatusError = "error" // cannot be linked (e.g. other filesystem) +) + +// Match kinds (how the host candidate was found). +const ( + KindPath = "path" // identical relative path + KindPathCase = "path_case" // relative path differs only by case + KindExtension = "extension" // same directory and title, different extension + KindFuzzy = "fuzzy" // same directory, normalized title matches + KindElsewhere = "elsewhere" // same artist, different album folder + KindNone = "none" +) + +type Match struct { + ID int64 `json:"id"` + PairID int64 `json:"pair_id"` + RelSlave string `json:"rel_slave"` + RelHost string `json:"rel_host"` + Kind string `json:"kind"` + Status string `json:"status"` + HostIno uint64 `json:"-"` + HostSize int64 `json:"host_size"` + HostMtime int64 `json:"host_mtime"` + SlaveIno uint64 `json:"-"` + SlaveSize int64 `json:"slave_size"` + SlaveMtime int64 `json:"slave_mtime"` + HostProbe json.RawMessage `json:"host_probe,omitempty"` + SlaveProbe json.RawMessage `json:"slave_probe,omitempty"` + Verdict json.RawMessage `json:"verdict,omitempty"` + Note string `json:"note"` + Ignored bool `json:"ignored"` + UpdatedAt int64 `json:"updated_at"` +} + +const matchCols = `id, pair_id, rel_slave, rel_host, kind, status, host_ino, host_size, host_mtime, + slave_ino, slave_size, slave_mtime, host_probe, slave_probe, verdict, note, ignored, updated_at` + +func scanMatch(r scanner) (Match, error) { + var m Match + var hp, sp, v string + err := r.Scan(&m.ID, &m.PairID, &m.RelSlave, &m.RelHost, &m.Kind, &m.Status, &m.HostIno, &m.HostSize, &m.HostMtime, + &m.SlaveIno, &m.SlaveSize, &m.SlaveMtime, &hp, &sp, &v, &m.Note, &m.Ignored, &m.UpdatedAt) + if hp != "" { + m.HostProbe = json.RawMessage(hp) + } + if sp != "" { + m.SlaveProbe = json.RawMessage(sp) + } + if v != "" { + m.Verdict = json.RawMessage(v) + } + return m, err +} + +func (s *Store) Match(ctx context.Context, id int64) (Match, error) { + m, err := scanMatch(s.DB.QueryRowContext(ctx, `SELECT `+matchCols+` FROM matches WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return m, ErrNotFound + } + return m, err +} + +func (s *Store) MatchBySlave(ctx context.Context, pairID int64, relSlave string) (Match, error) { + m, err := scanMatch(s.DB.QueryRowContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ? AND rel_slave = ?`, pairID, relSlave)) + if errors.Is(err, sql.ErrNoRows) { + return m, ErrNotFound + } + return m, err +} + +// AllMatches loads every match of a pair keyed by slave path (used by scans +// to reuse comparison and probe results for unchanged files). +func (s *Store) AllMatches(ctx context.Context, pairID int64) (map[string]Match, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE pair_id = ?`, pairID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]Match{} + for rows.Next() { + m, err := scanMatch(rows) + if err != nil { + return nil, err + } + out[m.RelSlave] = m + } + return out, rows.Err() +} + +type MatchFilter struct { + Status string // a status, "review" (different+similar), "ignored" or "" + Query string + Limit int + Offset int +} + +func (s *Store) Matches(ctx context.Context, pairID int64, f MatchFilter) ([]Match, int, error) { + where := []string{"pair_id = ?"} + args := []any{pairID} + switch f.Status { + case "": + case "review": + where = append(where, "status IN ('different','similar') AND ignored = 0") + case "ignored": + where = append(where, "ignored = 1") + default: + where = append(where, "status = ? AND ignored = 0") + args = append(args, f.Status) + } + if f.Query != "" { + where = append(where, "(rel_slave LIKE ? ESCAPE '\\' OR rel_host LIKE ? ESCAPE '\\')") + q := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(f.Query) + "%" + args = append(args, q, q) + } + w := strings.Join(where, " AND ") + var total int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM matches WHERE `+w, args...).Scan(&total); err != nil { + return nil, 0, err + } + if f.Limit <= 0 || f.Limit > 500 { + f.Limit = 100 + } + rows, err := s.DB.QueryContext(ctx, `SELECT `+matchCols+` FROM matches WHERE `+w+ + ` ORDER BY rel_slave LIMIT ? OFFSET ?`, append(args, f.Limit, f.Offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []Match{} + for rows.Next() { + m, err := scanMatch(rows) + if err != nil { + return nil, 0, err + } + out = append(out, m) + } + return out, total, rows.Err() +} + +// IdenticalIDs lists linkable identical matches (not ignored). +func (s *Store) IdenticalIDs(ctx context.Context, pairID int64) ([]int64, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT id FROM matches WHERE pair_id = ? AND status = 'identical' AND ignored = 0 ORDER BY rel_slave`, pairID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *Store) UpsertMatch(ctx context.Context, m *Match) error { + nullable := func(r json.RawMessage) string { return string(r) } + _, err := s.DB.ExecContext(ctx, `INSERT INTO matches (pair_id, rel_slave, rel_host, kind, status, host_ino, host_size, + host_mtime, slave_ino, slave_size, slave_mtime, host_probe, slave_probe, verdict, note, ignored, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pair_id, rel_slave) DO UPDATE SET rel_host = excluded.rel_host, kind = excluded.kind, + status = excluded.status, host_ino = excluded.host_ino, host_size = excluded.host_size, + host_mtime = excluded.host_mtime, slave_ino = excluded.slave_ino, slave_size = excluded.slave_size, + slave_mtime = excluded.slave_mtime, host_probe = excluded.host_probe, slave_probe = excluded.slave_probe, + verdict = excluded.verdict, note = excluded.note, ignored = excluded.ignored, updated_at = excluded.updated_at`, + m.PairID, m.RelSlave, m.RelHost, m.Kind, m.Status, m.HostIno, m.HostSize, m.HostMtime, m.SlaveIno, m.SlaveSize, + m.SlaveMtime, nullable(m.HostProbe), nullable(m.SlaveProbe), nullable(m.Verdict), m.Note, m.Ignored, now()) + return err +} + +func (s *Store) DeleteMatch(ctx context.Context, pairID int64, relSlave string) error { + _, err := s.DB.ExecContext(ctx, `DELETE FROM matches WHERE pair_id = ? AND rel_slave = ?`, pairID, relSlave) + return err +} + +// DeleteMatchesExcept removes rows for slave files that disappeared. +func (s *Store) DeleteMatchesExcept(ctx context.Context, pairID int64, keep map[string]bool) error { + existing, err := s.AllMatches(ctx, pairID) + if err != nil { + return err + } + for rel := range existing { + if !keep[rel] { + if err := s.DeleteMatch(ctx, pairID, rel); err != nil { + return err + } + } + } + return nil +} + +func (s *Store) SetIgnored(ctx context.Context, id int64, ignored bool) error { + _, err := s.DB.ExecContext(ctx, `UPDATE matches SET ignored = ? WHERE id = ?`, ignored, id) + return err +} + +// LinkedHostPaths returns host paths already linked into the slave under prefix. +func (s *Store) LinkedHostPaths(ctx context.Context, pairID int64, prefix string) ([]string, error) { + q := `SELECT rel_host FROM matches WHERE pair_id = ? AND status = 'linked'` + args := []any{pairID} + if prefix != "" { + q += ` AND rel_host LIKE ? ESCAPE '\'` + args = append(args, strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)+"/%") + } + rows, err := s.DB.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +type Summary struct { + Counts map[string]int `json:"counts"` + Review int `json:"review"` + Ignored int `json:"ignored"` + ReclaimableSize int64 `json:"reclaimable_bytes"` + LinkedSize int64 `json:"linked_bytes"` +} + +func (s *Store) Summary(ctx context.Context, pairID int64) (Summary, error) { + sum := Summary{Counts: map[string]int{}} + rows, err := s.DB.QueryContext(ctx, `SELECT status, ignored, COUNT(*), COALESCE(SUM(slave_size), 0) FROM matches WHERE pair_id = ? GROUP BY status, ignored`, pairID) + if err != nil { + return sum, err + } + defer rows.Close() + for rows.Next() { + var st string + var ign bool + var n int + var size int64 + if err := rows.Scan(&st, &ign, &n, &size); err != nil { + return sum, err + } + if ign { + sum.Ignored += n + continue + } + sum.Counts[st] += n + switch st { + case StatusIdentical: + sum.ReclaimableSize += size + case StatusLinked: + sum.LinkedSize += size + case StatusDifferent, StatusSimilar: + sum.Review += n + } + } + return sum, rows.Err() +} + +// ---- operations ---- + +type Operation struct { + ID int64 `json:"id"` + PairID int64 `json:"pair_id"` + Action string `json:"action"` + RelHost string `json:"rel_host"` + RelSlave string `json:"rel_slave"` + RelNew string `json:"rel_new"` + QuarantinePath string `json:"quarantine_path"` + QuarantineSide string `json:"quarantine_side"` + TmpPath string `json:"-"` + Bytes int64 `json:"bytes"` + DryRun bool `json:"dry_run"` + Trigger string `json:"trigger"` + Status string `json:"status"` + Error string `json:"error"` + CreatedAt int64 `json:"created_at"` + FinishedAt *int64 `json:"finished_at"` + UndoneAt *int64 `json:"undone_at"` + PurgedAt *int64 `json:"purged_at"` +} + +// Operation actions. +const ( + ActLink = "link" // slave replaced by hardlink of host + ActPromote = "promote" // host replaced by slave's file, then linked + ActBuild = "build" // host file linked into the slave (fresh library) + ActRemove = "remove" // slave hardlink removed (host copy kept) +) + +// Operation statuses. +const ( + OpRunning = "running" + OpDone = "done" + OpFailed = "failed" + OpPlanned = "planned" // dry run + OpInterrupted = "interrupted" +) + +const opCols = `id, pair_id, action, rel_host, rel_slave, rel_new, quarantine_path, quarantine_side, tmp_path, bytes, + dry_run, trigger, status, error, created_at, finished_at, undone_at, purged_at` + +func scanOp(r scanner) (Operation, error) { + var o Operation + var fin, und, pur sql.NullInt64 + err := r.Scan(&o.ID, &o.PairID, &o.Action, &o.RelHost, &o.RelSlave, &o.RelNew, &o.QuarantinePath, &o.QuarantineSide, + &o.TmpPath, &o.Bytes, &o.DryRun, &o.Trigger, &o.Status, &o.Error, &o.CreatedAt, &fin, &und, &pur) + if fin.Valid { + o.FinishedAt = &fin.Int64 + } + if und.Valid { + o.UndoneAt = &und.Int64 + } + if pur.Valid { + o.PurgedAt = &pur.Int64 + } + return o, err +} + +func (s *Store) CreateOp(ctx context.Context, o *Operation) error { + o.CreatedAt = now() + res, err := s.DB.ExecContext(ctx, `INSERT INTO operations (pair_id, action, rel_host, rel_slave, rel_new, quarantine_path, + quarantine_side, tmp_path, bytes, dry_run, trigger, status, error, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + o.PairID, o.Action, o.RelHost, o.RelSlave, o.RelNew, o.QuarantinePath, o.QuarantineSide, o.TmpPath, o.Bytes, + o.DryRun, o.Trigger, o.Status, o.Error, o.CreatedAt) + if err != nil { + return err + } + o.ID, _ = res.LastInsertId() + return nil +} + +func (s *Store) FinishOp(ctx context.Context, id int64, status, errMsg string) error { + _, err := s.DB.ExecContext(ctx, `UPDATE operations SET status = ?, error = ?, tmp_path = '', finished_at = ? WHERE id = ?`, + status, errMsg, now(), id) + return err +} + +func (s *Store) MarkUndone(ctx context.Context, id int64) error { + _, err := s.DB.ExecContext(ctx, `UPDATE operations SET undone_at = ? WHERE id = ?`, now(), id) + return err +} + +func (s *Store) MarkPurged(ctx context.Context, id int64) error { + _, err := s.DB.ExecContext(ctx, `UPDATE operations SET purged_at = ? WHERE id = ?`, now(), id) + return err +} + +func (s *Store) Op(ctx context.Context, id int64) (Operation, error) { + o, err := scanOp(s.DB.QueryRowContext(ctx, `SELECT `+opCols+` FROM operations WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return o, ErrNotFound + } + return o, err +} + +type OpFilter struct { + PairID int64 + Quarantine bool // only ops holding a live quarantined file + Limit int + Offset int +} + +func (s *Store) Ops(ctx context.Context, f OpFilter) ([]Operation, int, error) { + where := []string{"1=1"} + var args []any + if f.PairID != 0 { + where = append(where, "pair_id = ?") + args = append(args, f.PairID) + } + if f.Quarantine { + where = append(where, "quarantine_path != '' AND purged_at IS NULL AND undone_at IS NULL AND status IN ('done','interrupted')") + } + w := strings.Join(where, " AND ") + var total int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM operations WHERE `+w, args...).Scan(&total); err != nil { + return nil, 0, err + } + if f.Limit <= 0 || f.Limit > 500 { + f.Limit = 100 + } + rows, err := s.DB.QueryContext(ctx, `SELECT `+opCols+` FROM operations WHERE `+w+` ORDER BY id DESC LIMIT ? OFFSET ?`, + append(args, f.Limit, f.Offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + out := []Operation{} + for rows.Next() { + o, err := scanOp(rows) + if err != nil { + return nil, 0, err + } + out = append(out, o) + } + return out, total, rows.Err() +} + +// RunningOps returns operations left in the running state (after a crash). +func (s *Store) RunningOps(ctx context.Context) ([]Operation, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+opCols+` FROM operations WHERE status = 'running'`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Operation + for rows.Next() { + o, err := scanOp(rows) + if err != nil { + return nil, err + } + out = append(out, o) + } + return out, rows.Err() +} + +// ExpiredQuarantine returns quarantine-holding ops older than cutoff. +func (s *Store) ExpiredQuarantine(ctx context.Context, pairID, cutoff int64) ([]Operation, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT `+opCols+` FROM operations WHERE pair_id = ? AND quarantine_path != '' + AND purged_at IS NULL AND undone_at IS NULL AND status IN ('done','interrupted') AND created_at < ?`, pairID, cutoff) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Operation + for rows.Next() { + o, err := scanOp(rows) + if err != nil { + return nil, err + } + out = append(out, o) + } + return out, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..f7be70a --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,162 @@ +// Package store persists playlink state in SQLite. +package store + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + DB *sql.DB +} + +var migrations = []string{ + `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE pairs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + host_root TEXT NOT NULL, + slave_root TEXT NOT NULL, + quarantine_dir TEXT NOT NULL, + extensions TEXT NOT NULL, + auto_link_identical INTEGER NOT NULL DEFAULT 0, + scan_interval_minutes INTEGER NOT NULL DEFAULT 0, + retention_days INTEGER NOT NULL DEFAULT 30, + settle_minutes INTEGER NOT NULL DEFAULT 10, + last_scan_at INTEGER, + last_scan_error TEXT NOT NULL DEFAULT '', + last_scan_warnings TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL + ); + CREATE TABLE matches ( + id INTEGER PRIMARY KEY, + pair_id INTEGER NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, + rel_slave TEXT NOT NULL, + rel_host TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL, + status TEXT NOT NULL, + host_ino INTEGER NOT NULL DEFAULT 0, + host_size INTEGER NOT NULL DEFAULT 0, + host_mtime INTEGER NOT NULL DEFAULT 0, + slave_ino INTEGER NOT NULL DEFAULT 0, + slave_size INTEGER NOT NULL DEFAULT 0, + slave_mtime INTEGER NOT NULL DEFAULT 0, + host_probe TEXT NOT NULL DEFAULT '', + slave_probe TEXT NOT NULL DEFAULT '', + verdict TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + ignored INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE(pair_id, rel_slave) + ); + CREATE INDEX matches_pair_status ON matches(pair_id, status); + CREATE TABLE operations ( + id INTEGER PRIMARY KEY, + pair_id INTEGER NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, + action TEXT NOT NULL, + rel_host TEXT NOT NULL DEFAULT '', + rel_slave TEXT NOT NULL DEFAULT '', + rel_new TEXT NOT NULL DEFAULT '', + quarantine_path TEXT NOT NULL DEFAULT '', + quarantine_side TEXT NOT NULL DEFAULT '', + tmp_path TEXT NOT NULL DEFAULT '', + bytes INTEGER NOT NULL DEFAULT 0, + dry_run INTEGER NOT NULL DEFAULT 0, + trigger TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL, + error TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + finished_at INTEGER, + undone_at INTEGER, + purged_at INTEGER + ); + CREATE INDEX operations_pair ON operations(pair_id, created_at DESC);`, +} + +// Open opens (and migrates) the database in dataDir. +func Open(dataDir string) (*Store, error) { + dsn := "file:" + filepath.Join(dataDir, "playlink.db") + + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // SQLite: one writer; keeps things simple and safe. + s := &Store{DB: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) migrate() error { + if _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS schema_version (v INTEGER NOT NULL)`); err != nil { + return err + } + var v int + if err := s.DB.QueryRow(`SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&v); err != nil { + return err + } + for i := v; i < len(migrations); i++ { + tx, err := s.DB.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(migrations[i]); err != nil { + tx.Rollback() + return fmt.Errorf("migration %d: %w", i+1, err) + } + if _, err := tx.Exec(`INSERT INTO schema_version (v) VALUES (?)`, i+1); err != nil { + tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +func now() int64 { return time.Now().Unix() } + +// ---- settings ---- + +func (s *Store) Setting(ctx context.Context, key, def string) string { + var v string + if err := s.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v); err != nil { + return def + } + return v +} + +func (s *Store) SetSetting(ctx context.Context, key, value string) error { + _, err := s.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) + return err +} + +// DryRun is the global safety switch. It defaults to on for new installs. +func (s *Store) DryRun(ctx context.Context) bool { + return s.Setting(ctx, "dry_run", "1") == "1" +} diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..b85a72e --- /dev/null +++ b/web/embed.go @@ -0,0 +1,19 @@ +// Package web embeds the built single-page app. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// Dist returns the built UI rooted at dist/. +func Dist() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + panic(err) + } + return sub +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..db89679 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + playlink + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..c6a3dcb --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1949 @@ +{ + "name": "playlink-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playlink-web", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.103.2", + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.2.tgz", + "integrity": "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.2.tgz", + "integrity": "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz", + "integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5214171 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "playlink-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b" + }, + "dependencies": { + "@tanstack/react-query": "^5.103.2", + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.0" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..37a07a5 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..f63c761 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Activity, FlaskConical, LayoutGrid, LogOut, Settings as SettingsIcon, ShieldCheck } from "lucide-react"; +import { api, type AuthState, type Info } from "./api"; +import { Login } from "./pages/Login"; +import { Dashboard } from "./pages/Dashboard"; +import { PairPage } from "./pages/PairPage"; +import { ActivityPage } from "./pages/Activity"; +import { SettingsPage } from "./pages/Settings"; +import { Spinner, cx } from "./ui"; +import { useToast } from "./toast"; + +export function useRoute() { + const [hash, setHash] = useState(() => window.location.hash.slice(1) || "/"); + useEffect(() => { + const h = () => setHash(window.location.hash.slice(1) || "/"); + window.addEventListener("hashchange", h); + return () => window.removeEventListener("hashchange", h); + }, []); + return hash.split("?")[0].split("/").filter(Boolean); +} + +export const go = (path: string) => (window.location.hash = path); + +export function useInfo() { + return useQuery({ queryKey: ["info"], queryFn: () => api.get("/info") }); +} + +export function App() { + const auth = useQuery({ queryKey: ["auth"], queryFn: () => api.get("/auth/state") }); + if (auth.isLoading) { + return ( +
+ +
+ ); + } + if (!auth.data?.authenticated) return ; + return ; +} + +function Shell({ username }: { username: string }) { + const route = useRoute(); + const qc = useQueryClient(); + const toast = useToast(); + const info = useInfo(); + const dry = info.data?.dry_run ?? true; + + const setDry = useMutation({ + mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }), + onSuccess: (_, v) => { + qc.invalidateQueries({ queryKey: ["info"] }); + toast("ok", v ? "Dry run enabled: no files will be changed." : "Dry run disabled: operations will now change files."); + }, + }); + const logout = useMutation({ + mutationFn: () => api.post("/auth/logout"), + onSuccess: () => qc.invalidateQueries(), + }); + + const nav = [ + { to: "/", label: "Libraries", icon: LayoutGrid, active: route.length === 0 || route[0] === "pair" }, + { to: "/activity", label: "Activity", icon: Activity, active: route[0] === "activity" }, + { to: "/settings", label: "Settings", icon: SettingsIcon, active: route[0] === "settings" }, + ]; + + let page; + if (route[0] === "pair" && route[1]) page = ; + else if (route[0] === "activity") page = ; + else if (route[0] === "settings") page = ; + else page = ; + + return ( +
+
+
+ + + playlink + + +
+ + {username} + +
+
+
+ {dry && ( +
+ Dry run is on. Scans and reviews work normally, but link, promote, build and remove actions are only recorded as planned in Activity. Nothing on disk changes. +
+ )} +
{page}
+
playlink {info.data?.version}
+
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..6b55b99 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,160 @@ +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`/api${path}`, { + method, + credentials: "same-origin", + headers: { + "X-Playlink-Request": "1", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, data.error ?? res.statusText); + return data as T; +} + +export const api = { + get: (p: string) => request("GET", p), + post: (p: string, b: unknown = {}) => request("POST", p, b), + put: (p: string, b: unknown) => request("PUT", p, b), + del: (p: string) => request("DELETE", p), +}; + +export type AuthState = { setup_required: boolean; authenticated: boolean; username?: string }; + +export type Info = { + version: string; + allowed_roots: string[]; + default_extensions: string[]; + dry_run: boolean; +}; + +export type Job = { + id: number; + pair_id: number; + kind: string; + state: "running" | "done" | "failed" | "cancelled"; + phase: string; + done: number; + total: number; + message: string; + error: string; + results: Record; + skipped: string[]; + started_at: number; + finished_at?: number; +}; + +export type Summary = { + counts: Record; + review: number; + ignored: number; + reclaimable_bytes: number; + linked_bytes: number; +}; + +export type Pair = { + id: number; + name: string; + host_root: string; + slave_root: string; + quarantine_dir: string; + extensions: string[]; + auto_link_identical: boolean; + scan_interval_minutes: number; + retention_days: number; + settle_minutes: number; + last_scan_at: number | null; + last_scan_error: string; + last_scan_warnings: string[]; + summary: Summary; + job: Job | null; +}; + +export type PairInput = Pick< + Pair, + | "name" + | "host_root" + | "slave_root" + | "quarantine_dir" + | "extensions" + | "auto_link_identical" + | "scan_interval_minutes" + | "retention_days" + | "settle_minutes" +>; + +export type ProbeInfo = { + codec: string; + codec_long?: string; + format?: string; + lossless: boolean; + bitrate: number; + sample_rate: number; + bit_depth?: number; + channels: number; + duration: number; + title?: string; + artist?: string; + album?: string; + error?: string; +}; + +export type Verdict = { better: "host" | "slave" | "equal" | "unknown"; duration_delta: number; notes: string[] | null }; + +export type Status = "linked" | "identical" | "different" | "similar" | "unmatched" | "error"; + +export type Match = { + id: number; + pair_id: number; + rel_slave: string; + rel_host: string; + kind: "path" | "path_case" | "extension" | "fuzzy" | "elsewhere" | "none"; + status: Status; + host_size: number; + host_mtime: number; + slave_size: number; + slave_mtime: number; + host_probe?: ProbeInfo; + slave_probe?: ProbeInfo; + verdict?: Verdict; + note: string; + ignored: boolean; +}; + +export type Operation = { + id: number; + pair_id: number; + action: "link" | "promote" | "build" | "remove"; + rel_host: string; + rel_slave: string; + rel_new: string; + quarantine_path: string; + quarantine_side: string; + bytes: number; + dry_run: boolean; + trigger: string; + status: "running" | "done" | "failed" | "planned" | "interrupted"; + error: string; + created_at: number; + finished_at: number | null; + undone_at: number | null; + purged_at: number | null; +}; + +export type TreeEntry = { + name: string; + rel: string; + dir: boolean; + size?: number; + state?: "linked" | "conflict" | "missing"; + linked?: number; +}; + +export type Page = { items: T[]; total: number }; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..37dd171 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,14 @@ +@import "tailwindcss"; + +@theme { + --color-bg: #0b0c10; + --color-panel: #13151b; + --color-panel-2: #1a1d25; + --color-line: #262a35; + --color-muted: #8a91a3; + --color-accent: #8b5cf6; +} + +html, body, #root { height: 100%; } +body { background: var(--color-bg); color: #e6e8ee; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } +code, .mono { font-family: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace; } diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..25dec08 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { App } from "./App"; +import { ToastProvider } from "./toast"; +import "./index.css"; + +const qc = new QueryClient({ + defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: true, staleTime: 2000 } }, +}); + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx new file mode 100644 index 0000000..7bf74e4 --- /dev/null +++ b/web/src/pages/Activity.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Archive, History, RotateCcw, Trash2 } from "lucide-react"; +import { api, type Operation, type Page, type Pair } from "../api"; +import { Badge, Button, Card, Empty, Modal, Spinner, cx, formatAgo, formatBytes } from "../ui"; +import { useToast } from "../toast"; + +export function ActivityPage() { + const [view, setView] = useState<"all" | "quarantine">("all"); + return ( +
+
+
+

Activity

+

Every change playlink makes is recorded here and can be undone.

+
+
+ {(["all", "quarantine"] as const).map((v) => ( + + ))} +
+
+ +
+ ); +} + +const actionLabel: Record = { + link: "Linked slave → host", + promote: "Promoted slave → host", + build: "Built into slave", + remove: "Removed from slave", +}; + +const statusTone: Record = { + done: "green", + failed: "red", + planned: "blue", + interrupted: "amber", + running: "gray", +}; + +export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: boolean }) { + const [page, setPage] = useState(0); + const [confirm, setConfirm] = useState(null); + const qc = useQueryClient(); + const toast = useToast(); + const pairs = useQuery({ queryKey: ["pairs"], queryFn: () => api.get("/pairs") }); + const names = Object.fromEntries((pairs.data ?? []).map((p) => [p.id, p.name])); + const ops = useQuery({ + queryKey: ["ops", pairId, quarantine, page], + queryFn: () => api.get>(`/operations?limit=50&offset=${page * 50}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`), + placeholderData: keepPreviousData, + }); + const act = useMutation({ + mutationFn: ({ op, kind }: { op: Operation; kind: "undo" | "purge" }) => api.post(`/operations/${op.id}/${kind}`), + onSuccess: (_, { kind }) => { + toast("ok", kind === "undo" ? "Operation undone" : "Quarantined file permanently deleted"); + setConfirm(null); + qc.invalidateQueries({ queryKey: ["ops"] }); + qc.invalidateQueries({ queryKey: ["matches"] }); + qc.invalidateQueries({ queryKey: ["pair"] }); + }, + onError: (e) => toast("err", e.message), + }); + + if (ops.isLoading) return ; + const items = ops.data?.items ?? []; + + return ( + <> + + {items.length === 0 && ( + : } title={quarantine ? "Quarantine is empty" : "No activity yet"}> + {quarantine ? "Originals replaced by a non-identical file show up here until they are purged." : undefined} + + )} + {items.map((o) => { + const canUndo = o.status === "done" && !o.undone_at && !o.purged_at; + const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted"); + return ( +
+
+
{actionLabel[o.action]}
+
+ {formatAgo(o.created_at)} · {o.trigger} + {!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`} +
+
+
+
+ {o.rel_slave} +
+ {o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_host && ( +
now {o.rel_new}
+ )} + {o.error &&
{o.error}
} + {inQuarantine && ( +
+ {o.quarantine_side} original kept in quarantine +
+ )} +
+
+ {o.bytes > 0 && o.status === "done" && !o.undone_at && −{formatBytes(o.bytes)}} + {o.dry_run ? "dry run" : o.status} + {o.undone_at && undone} + {o.purged_at && purged} +
+
+ {canUndo && ( + + )} + {inQuarantine && ( + + )} +
+
+ ); + })} +
+ {ops.data && ops.data.total > 50 && ( +
+ + +
+ )} + setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}> + {confirm && ( +

+ {confirm.kind === "purge" ? ( + <> + The quarantined {confirm.op.quarantine_side} original of {confirm.op.rel_slave} will be deleted.{" "} + This cannot be undone. + + ) : ( + undoText(confirm.op) + )} +

+ )} +
+ + +
+
+ + ); +} + +function undoText(o: Operation) { + switch (o.action) { + case "link": + return o.quarantine_path + ? "The original slave file is restored from quarantine and the hardlink is removed." + : "The slave gets its own independent copy of the file again, which uses disk space."; + case "promote": + return "The original host file is restored from quarantine. The slave keeps its own file."; + case "build": + return "The hardlink is removed from the slave library. The host file is not affected."; + case "remove": + return "The hardlink is recreated in the slave library."; + } +} diff --git a/web/src/pages/Build.tsx b/web/src/pages/Build.tsx new file mode 100644 index 0000000..e432a28 --- /dev/null +++ b/web/src/pages/Build.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronRight, Disc3, FileAudio, Folder, Link2, Unlink } from "lucide-react"; +import { api, type Pair, type TreeEntry } from "../api"; +import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui"; +import { useToast } from "../toast"; + +export function Build({ pair }: { pair: Pair }) { + const [path, setPath] = useState(""); + const [sel, setSel] = useState>(new Set()); + const [confirm, setConfirm] = useState(null); + const qc = useQueryClient(); + const toast = useToast(); + const tree = useQuery({ + queryKey: ["tree", pair.id, path], + queryFn: () => api.get(`/pairs/${pair.id}/tree?path=${encodeURIComponent(path)}`), + }); + const run = useMutation({ + mutationFn: (kind: "build" | "remove") => api.post(`/pairs/${pair.id}/${kind}`, { paths: [...sel] }), + onSuccess: () => { + setConfirm(null); + setSel(new Set()); + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + }, + onError: (e) => toast("err", e.message), + }); + const running = pair.job?.state === "running"; + + const crumbs = path ? path.split("/") : []; + const toggle = (rel: string) => + setSel((s) => { + const n = new Set(s); + n.has(rel) ? n.delete(rel) : n.add(rel); + return n; + }); + const all = tree.data ?? []; + const allSelected = all.length > 0 && all.every((e) => sel.has(e.rel)); + + return ( +
+ + Build the slave library from the host. Browse the host library, pick artists, albums or songs, and playlink hardlinks them into the slave + library at the same Artist/Album/Title.ext path. No extra space is used. Existing slave files are never + overwritten, and Remove only deletes slave files that are hardlinks of the host file, so the host copy is always kept. + + +
+ +
+ {sel.size > 0 && {sel.size} selected} + + +
+
+ + + {tree.isLoading && ( +
+ +
+ )} + {tree.error &&
{tree.error.message}
} + {tree.data?.length === 0 && } title="Empty folder" />} + {all.length > 0 && ( + + )} + {all.map((e) => ( +
+ toggle(e.rel)} /> + {e.dir ? : } + {e.dir ? ( + + ) : ( + {e.name} + )} + {e.dir && !!e.linked && {e.linked} linked} + {e.state === "linked" && in slave} + {e.state === "conflict" && different file in slave} + {!e.dir && {formatBytes(e.size ?? 0)}} +
+ ))} +
+

Folder link counts come from the last scan. Song states are live.

+ + setConfirm(null)} title={confirm === "build" ? "Link into slave library?" : "Remove from slave library?"}> +

+ {confirm === "build" ? ( + <> + Every audio file in the {sel.size} selected item(s) will be hardlinked into {pair.slave_root} at the same + relative path. Songs that already exist in the slave are skipped. + + ) : ( + <> + Slave files in the {sel.size} selected item(s) that are hardlinks of the host file will be deleted from{" "} + {pair.slave_root}. The host copy is kept. Any other slave file is left untouched. + + )} +

+
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx new file mode 100644 index 0000000..fe7174f --- /dev/null +++ b/web/src/pages/Dashboard.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, ArrowRight, Library, Plus } from "lucide-react"; +import { api, type Pair } from "../api"; +import { Badge, Button, Card, Empty, Spinner, formatAgo, formatBytes, statusTone } from "../ui"; +import { PairForm } from "./PairForm"; +import { JobBar, ScanButton } from "./PairPage"; + +export function Dashboard() { + const [creating, setCreating] = useState(false); + const pairs = useQuery({ + queryKey: ["pairs"], + queryFn: () => api.get("/pairs"), + refetchInterval: (q) => (q.state.data?.some((p) => p.job?.state === "running") ? 1000 : 15000), + }); + + return ( +
+
+
+

Libraries

+

Each pair links matching songs from a slave library to the host library's copy.

+
+ +
+ + {pairs.isLoading && } + {pairs.data?.length === 0 && ( + + } title="No library pairs yet"> + Create a pair with a host library (e.g. Albums) and a slave library (e.g. your curated Singles). playlink finds songs in + both and replaces the slave copy with a hardlink to the host copy, so each song is stored only once. +
+ +
+
+
+ )} + +
+ {pairs.data?.map((p) => )} +
+ setCreating(false)} /> +
+ ); +} + +function PairCard({ pair }: { pair: Pair }) { + const c = pair.summary.counts; + const stats: [string, number][] = [ + ["linked", c.linked ?? 0], + ["identical", c.identical ?? 0], + ["review", pair.summary.review], + ["unmatched", c.unmatched ?? 0], + ]; + return ( + +
+
+ + {pair.name} + +
+
+ host {pair.host_root} +
+
+ slave {pair.slave_root} +
+
+
+ +
+ +
+ {stats.map(([k, v]) => ( + +
{v}
+ {k} +
+ ))} +
+ +
+ Reclaimable: {formatBytes(pair.summary.reclaimable_bytes)} + Deduplicated: {formatBytes(pair.summary.linked_bytes)} + Last scan: {formatAgo(pair.last_scan_at)} + {pair.auto_link_identical && auto-link} + {pair.scan_interval_minutes > 0 && every {pair.scan_interval_minutes}m} +
+ + {pair.job?.state === "running" && } + {pair.last_scan_error && ( +
+ {pair.last_scan_error} +
+ )} + {pair.last_scan_warnings.length > 0 && ( +
+ {pair.last_scan_warnings.length} layout warning{pair.last_scan_warnings.length > 1 && "s"} +
+ )} + + Open + +
+ ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..15de642 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { Alert, Button, Card, Field, inputCls } from "../ui"; + +export function Login({ setup }: { setup: boolean }) { + const qc = useQueryClient(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const m = useMutation({ + mutationFn: () => { + if (setup && password !== confirm) throw new Error("Passwords do not match"); + return api.post(setup ? "/auth/setup" : "/auth/login", { username, password }); + }, + onSuccess: () => qc.invalidateQueries(), + }); + return ( +
+ +
+ +
+
playlink
+
{setup ? "Create your admin account" : "Sign in"}
+
+
+
{ + e.preventDefault(); + m.mutate(); + }} + > + + setUsername(e.target.value)} autoFocus /> + + + setPassword(e.target.value)} + /> + + {setup && ( + + setConfirm(e.target.value)} /> + + )} + {m.error && {m.error.message}} + +
+
+
+ ); +} diff --git a/web/src/pages/PairForm.tsx b/web/src/pages/PairForm.tsx new file mode 100644 index 0000000..ad22cf3 --- /dev/null +++ b/web/src/pages/PairForm.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, ChevronLeft, Folder, FolderOpen } from "lucide-react"; +import { api, type Pair, type PairInput } from "../api"; +import { useInfo } from "../App"; +import { Alert, Button, Field, Modal, Toggle, inputCls } from "../ui"; +import { useToast } from "../toast"; + +type Validation = { ok: boolean; error?: string; warnings: string[] }; + +export function PairForm({ pair, open, onClose }: { pair?: Pair; open: boolean; onClose: () => void }) { + const info = useInfo(); + const qc = useQueryClient(); + const toast = useToast(); + const blank: PairInput = { + name: "Music", + host_root: "", + slave_root: "", + quarantine_dir: "", + extensions: [], + auto_link_identical: false, + scan_interval_minutes: 0, + retention_days: 30, + settle_minutes: 10, + }; + const [f, setF] = useState(pair ?? blank); + const [exts, setExts] = useState((pair?.extensions ?? []).join(", ")); + const [picker, setPicker] = useState(null); + const [validation, setValidation] = useState(null); + + useEffect(() => { + if (open) { + setF(pair ?? blank); + setExts((pair?.extensions ?? info.data?.default_extensions ?? []).join(", ")); + setValidation(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, pair?.id]); + + // Send only editable fields: the API rejects unknown keys. + const body = (): PairInput => ({ + name: f.name, + host_root: f.host_root, + slave_root: f.slave_root, + quarantine_dir: f.quarantine_dir, + auto_link_identical: f.auto_link_identical, + scan_interval_minutes: f.scan_interval_minutes, + retention_days: f.retention_days, + settle_minutes: f.settle_minutes, + extensions: exts.split(",").map((s) => s.trim()).filter(Boolean), + }); + const set = (k: K, v: PairInput[K]) => { + setF((p) => ({ ...p, [k]: v })); + setValidation(null); + }; + + const validate = useMutation({ + mutationFn: () => api.post("/pairs/validate", body()), + onSuccess: setValidation, + }); + const save = useMutation({ + mutationFn: () => (pair ? api.put<{ warnings: string[] }>(`/pairs/${pair.id}`, body()) : api.post<{ warnings: string[] }>("/pairs", body())), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pairs"] }); + qc.invalidateQueries({ queryKey: ["pair"] }); + toast("ok", pair ? "Library pair updated" : "Library pair created. Run a scan to find matches."); + onClose(); + }, + }); + + return ( + +
+ + set("name", e.target.value)} /> + +
+ set("host_root", v)} + onBrowse={() => setPicker("host_root")} + /> + set("slave_root", v)} + onBrowse={() => setPicker("slave_root")} + /> +
+ + Both libraries should use the same Artist/Album/Title.ext layout. Files are matched by relative path first. + Both must be on the same filesystem. On Unraid, mount one common parent folder into the container instead of two separate volumes. + + set("quarantine_dir", v)} + onBrowse={() => setPicker("quarantine_dir")} + /> + + setExts(e.target.value)} /> + +
+ + set("scan_interval_minutes", Number(e.target.value))} /> + + + set("retention_days", Number(e.target.value))} /> + + + set("settle_minutes", Number(e.target.value))} /> + +
+
+ set("auto_link_identical", v)} + label={Automatically link byte-identical files after each scan} + /> +

+ Only files whose relative paths match and whose bytes are identical (verified with a full compare right before linking). + Anything else always waits for manual review. Dry run still applies. +

+
+ + {validation && !validation.ok && {validation.error}} + {validation?.ok && validation.warnings.length === 0 && Looks good. Both folders are valid and on the same filesystem.} + {validation?.warnings.map((w) => ( + }> + {w} + + ))} + {save.error && {save.error.message}} + +
+ + + +
+
+ {picker && ( + { + set(picker, p); + setPicker(null); + }} + onClose={() => setPicker(null)} + /> + )} +
+ ); +} + +function PathField(props: { label: string; hint: string; value: string; placeholder?: string; onChange: (v: string) => void; onBrowse: () => void }) { + return ( + +
+ props.onChange(e.target.value)} /> + +
+
+ ); +} + +function FolderPicker({ start, onPick, onClose }: { start: string; onPick: (p: string) => void; onClose: () => void }) { + const [path, setPath] = useState(start); + const q = useQuery({ + queryKey: ["browse", path], + queryFn: () => api.get<{ path: string; parent: string; dirs: string[] }>(`/browse?path=${encodeURIComponent(path)}`), + retry: false, + }); + return ( + +
+ + {q.data?.path || "Allowed roots"} +
+ {q.error && ( + + {q.error.message}{" "} + + + )} +
+ {q.data?.dirs.map((d) => ( + + ))} + {q.data && q.data.dirs.length === 0 &&
No subfolders
} +
+
+ + +
+
+ ); +} diff --git a/web/src/pages/PairPage.tsx b/web/src/pages/PairPage.tsx new file mode 100644 index 0000000..29998dc --- /dev/null +++ b/web/src/pages/PairPage.tsx @@ -0,0 +1,221 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, ChevronLeft, Pencil, RefreshCw, Square, Trash2 } from "lucide-react"; +import { api, type Job, type Pair } from "../api"; +import { Alert, Badge, Button, Card, Modal, Spinner, cx, formatAgo } from "../ui"; +import { useToast } from "../toast"; +import { go } from "../App"; +import { PairForm } from "./PairForm"; +import { Review } from "./Review"; +import { Build } from "./Build"; +import { OpsTable } from "./Activity"; + +export function usePair(id: number) { + return useQuery({ + queryKey: ["pair", id], + queryFn: () => api.get(`/pairs/${id}`), + refetchInterval: (q) => (q.state.data?.job?.state === "running" ? 1000 : 15000), + }); +} + +// useJobFinished invalidates dependent data when a pair's job completes. +function useJobFinished(pair: Pair | undefined) { + const qc = useQueryClient(); + const toast = useToast(); + const prev = useRef(undefined); + useEffect(() => { + const st = pair?.job?.state; + if (prev.current === "running" && st && st !== "running") { + qc.invalidateQueries({ queryKey: ["matches"] }); + qc.invalidateQueries({ queryKey: ["tree"] }); + qc.invalidateQueries({ queryKey: ["ops"] }); + qc.invalidateQueries({ queryKey: ["pairs"] }); + const j = pair!.job!; + if (st === "done") toast("ok", `${j.kind} finished${j.message ? `: ${j.message}` : ""}`); + else toast("err", `${j.kind} ${st}: ${j.error}`); + } + prev.current = st; + }, [pair?.job?.state]); // eslint-disable-line react-hooks/exhaustive-deps +} + +export function ScanButton({ pair }: { pair: Pair }) { + const qc = useQueryClient(); + const toast = useToast(); + const running = pair.job?.state === "running"; + const scan = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/scan`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pairs"] }); + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + }, + onError: (e) => toast("err", e.message), + }); + const cancel = useMutation({ mutationFn: () => api.post(`/pairs/${pair.id}/job/cancel`) }); + if (running) + return ( + + ); + return ( + + ); +} + +export function JobBar({ job, className }: { job: Job; className?: string }) { + const pct = job.total > 0 ? Math.round((job.done / job.total) * 100) : null; + return ( +
+
+ + {job.phase || job.kind} + + {job.total > 0 ? `${job.done} / ${job.total}` : "…"} +
+
+
+
+
+ ); +} + +function LastJob({ job }: { job: Job }) { + const [open, setOpen] = useState(false); + if (job.state === "running") return ; + const results = Object.entries(job.results).filter(([, v]) => v > 0); + return ( +
+ {job.kind}: {job.state} + {job.message || job.error} + {results.map(([k, v]) => ( + + {k} {v} + + ))} + {(job.skipped?.length ?? 0) > 0 && ( + + )} + setOpen(false)} title="Skipped items" wide> +
    + {(job.skipped ?? []).map((s, i) => ( +
  • + {s} +
  • + ))} +
+
+
+ ); +} + +const tabs = [ + { id: "review", label: "Review" }, + { id: "build", label: "Build" }, + { id: "history", label: "History" }, +]; + +export function PairPage({ id, tab }: { id: number; tab: string }) { + const q = usePair(id); + const qc = useQueryClient(); + const toast = useToast(); + const [editing, setEditing] = useState(false); + const [deleting, setDeleting] = useState(false); + useJobFinished(q.data); + const del = useMutation({ + mutationFn: () => api.del(`/pairs/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["pairs"] }); + toast("ok", "Pair removed. No files were touched."); + go("/"); + }, + onError: (e) => toast("err", e.message), + }); + + if (q.isLoading) return ; + if (q.error || !q.data) return {q.error?.message ?? "Not found"}; + const pair = q.data; + + return ( +
+
+
+ + Libraries + +

{pair.name}

+
+ + host {pair.host_root} + + + slave {pair.slave_root} + +
+
+
+ Scanned {formatAgo(pair.last_scan_at)} + + + +
+
+ + {pair.job && ( + + + + )} + {pair.last_scan_error && Last scan failed: {pair.last_scan_error}} + {pair.last_scan_warnings.map((w) => ( + }> + {w} + + ))} + + + + {tab === "review" && } + {tab === "build" && } + {tab === "history" && } + + setEditing(false)} /> + setDeleting(false)} title="Remove library pair?"> +

+ This deletes the pair's configuration, scan results and history from playlink. No files on disk are changed, and existing + hardlinks stay in place. Quarantined files stay in {pair.quarantine_dir}, but you can no longer restore them from the UI. +

+
+ + +
+
+
+ ); +} diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx new file mode 100644 index 0000000..14a5451 --- /dev/null +++ b/web/src/pages/Review.tsx @@ -0,0 +1,398 @@ +import { useEffect, useState } from "react"; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react"; +import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api"; +import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui"; +import { useToast } from "../toast"; + +const filters = [ + { id: "review", label: "Needs review" }, + { id: "identical", label: "Identical" }, + { id: "linked", label: "Linked" }, + { id: "unmatched", label: "Slave only" }, + { id: "error", label: "Errors" }, + { id: "ignored", label: "Ignored" }, + { id: "", label: "All" }, +]; + +const kindLabel: Record = { + path: "same path", + path_case: "path differs in case", + extension: "other format", + fuzzy: "similar title", + elsewhere: "other album folder", + none: "no match", +}; + +function hashParam(name: string) { + const q = window.location.hash.split("?")[1]; + return new URLSearchParams(q ?? "").get(name); +} + +const PAGE = 50; + +export function Review({ pair }: { pair: Pair }) { + const qc = useQueryClient(); + const toast = useToast(); + const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical")); + const [q, setQ] = useState(""); + const [debounced, setDebounced] = useState(""); + const [page, setPage] = useState(0); + const [confirmBulk, setConfirmBulk] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setDebounced(q), 250); + return () => clearTimeout(t); + }, [q]); + useEffect(() => setPage(0), [status, debounced]); + + const list = useQuery({ + queryKey: ["matches", pair.id, status, debounced, page], + queryFn: () => + api.get>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${page * PAGE}`), + placeholderData: keepPreviousData, + }); + + const bulk = useMutation({ + mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`), + onSuccess: () => { + setConfirmBulk(false); + qc.invalidateQueries({ queryKey: ["pair", pair.id] }); + }, + onError: (e) => toast("err", e.message), + }); + + const c = pair.summary.counts; + const count = (id: string) => + id === "review" ? pair.summary.review : id === "ignored" ? pair.summary.ignored : id === "" ? undefined : c[id] ?? 0; + const identical = c.identical ?? 0; + const running = pair.job?.state === "running"; + + return ( +
+
+ {filters.map((f) => ( + + ))} +
+ + setQ(e.target.value)} /> +
+
+ + {status === "identical" && identical > 0 && ( + +
+ {identical} byte-identical files ({formatBytes(pair.summary.reclaimable_bytes)}) can be replaced with hardlinks. +
Each one is compared byte for byte again right before linking. No quarantine is needed because the data stays in the host.
+
+ +
+ )} + + + {list.isLoading && ( +
+ +
+ )} + {list.data?.items.length === 0 && ( + } title={pair.last_scan_at ? "Nothing here" : "Not scanned yet"}> + {pair.last_scan_at ? "No files match this filter." : "Run a scan to compare the two libraries."} + + )} + {list.data?.items.map((m) => )} +
+ + {list.data && list.data.total > PAGE && ( +
+ + {page * PAGE + 1}–{Math.min((page + 1) * PAGE, list.data.total)} of {list.data.total} + +
+ + +
+
+ )} + + setConfirmBulk(false)} title="Link all identical files?"> +

+ Each of the {identical} slave files will be compared byte for byte with its host counterpart once more, then atomically + replaced with a hardlink to the host file. Files whose paths differ only by letter case are included. +

+
+ + +
+
+
+ ); +} + +function PathLine({ m }: { m: Match }) { + const same = m.rel_host === m.rel_slave || !m.rel_host; + return ( +
+
+ {m.rel_slave} +
+ {!same && ( +
+ {m.rel_host} +
+ )} +
+ ); +} + +function qualityLabel(p?: ProbeInfo) { + if (!p || p.error) return "?"; + if (p.lossless) return `${p.codec.toUpperCase()} ${p.bit_depth || "?"}/${(p.sample_rate / 1000).toFixed(1)}`; + return `${p.codec.toUpperCase()} ${Math.round(p.bitrate / 1000)}k`; +} + +function MatchRow({ m }: { m: Match }) { + const reviewable = m.status === "different" || m.status === "similar"; + const [open, setOpen] = useState(false); + const qc = useQueryClient(); + const toast = useToast(); + const refresh = () => { + qc.invalidateQueries({ queryKey: ["matches"] }); + qc.invalidateQueries({ queryKey: ["pair", m.pair_id] }); + }; + const link = useMutation({ + mutationFn: () => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, {}), + onSuccess: (op) => { + toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Linked"); + refresh(); + }, + onError: (e) => toast("err", e.message), + }); + const ignore = useMutation({ + mutationFn: () => api.post(`/matches/${m.id}/ignore`, { ignored: !m.ignored }), + onSuccess: refresh, + }); + const better = m.verdict?.better; + + return ( +
+
+ +
+ {m.status} + {m.kind !== "path" && m.kind !== "none" && {kindLabel[m.kind]}} +
+
reviewable && setOpen(!open)}> + + {m.note &&
{m.note}
} +
+ {reviewable && ( +
+ {qualityLabel(m.host_probe)} + vs + {qualityLabel(m.slave_probe)} + {better === "slave" && ( + + host lower + + )} + {better === "host" && host better} + {better === "equal" && equal} +
+ )} +
{formatBytes(m.slave_size)}
+
+ {m.status === "identical" && !m.ignored && ( + + )} + {reviewable && !m.ignored && ( + + )} + {(reviewable || m.status === "identical") && ( + + )} +
+
+ {open && reviewable && } +
+ ); +} + +const rows: [string, (p: ProbeInfo, size: number) => string][] = [ + ["Codec", (p) => `${p.codec}${p.lossless ? " (lossless)" : ""}`], + ["Bitrate", (p) => (p.bitrate ? `${Math.round(p.bitrate / 1000)} kbps` : "–")], + ["Sample rate", (p) => (p.sample_rate ? `${(p.sample_rate / 1000).toFixed(1)} kHz` : "–")], + ["Bit depth", (p) => (p.bit_depth ? `${p.bit_depth}-bit` : "–")], + ["Channels", (p) => String(p.channels || "–")], + ["Duration", (p) => formatDuration(p.duration)], + ["Size", (_, s) => formatBytes(s)], + ["Title tag", (p) => p.title || "–"], + ["Album tag", (p) => p.album || "–"], +]; + +function ComparePanel({ m, onDone }: { m: Match; onDone: () => void }) { + const toast = useToast(); + const [confirm, setConfirm] = useState(null); + const hp = m.host_probe; + const sp = m.slave_probe; + const v = m.verdict; + const renamed = m.rel_host.split("/").pop() !== m.rel_slave.split("/").pop(); + + const link = useMutation({ + mutationFn: (allow: boolean) => api.post<{ dry_run: boolean }>(`/matches/${m.id}/link`, { allow_downgrade: allow }), + onSuccess: (op) => { + setConfirm(null); + toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Linked. The original slave file is in quarantine."); + onDone(); + }, + onError: (e) => { + if (e instanceof ApiError && e.status === 409 && e.message.includes("lower quality")) setConfirm("downgrade"); + else toast("err", e.message); + }, + }); + const promote = useMutation({ + mutationFn: () => api.post<{ dry_run: boolean }>(`/matches/${m.id}/promote`, { confirm: true }), + onSuccess: (op) => { + setConfirm(null); + toast("ok", op.dry_run ? "Planned (dry run): nothing changed" : "Slave file promoted to host. The old host file is in quarantine."); + onDone(); + }, + onError: (e) => toast("err", e.message), + }); + + return ( +
+ {(!hp || !sp) && No codec details available (ffprobe missing or failed). Compare the files yourself before linking.} + {hp && sp && ( +
+ + + + + + + + + + {rows.map(([label, f]) => { + const a = hp.error ? "–" : f(hp, m.host_size); + const b = sp.error ? "–" : f(sp, m.slave_size); + return ( + + + + + + ); + })} + +
+ host {better(v?.better === "host")} + + slave {better(v?.better === "slave")} +
{label}{a}{b}
+
+ )} +
+ {v?.notes?.map((n) => ( + }> + {n} + + ))} + {v?.better === "slave" && ( + }> + The host copy is lower quality. The host is normally kept because it belongs to a complete album. You can still link to it (the + slave's better copy goes to quarantine), or override and make the slave's copy the host file. + + )} + {renamed && ( + + File names differ. Linking puts {m.rel_host.split("/").pop()} into the slave folder in place of{" "} + {m.rel_slave.split("/").pop()}, so playlists that point at the old name will need updating. + + )} +
+
+ + +
+

Whatever gets replaced is kept in quarantine and can be restored from History.

+ + setConfirm(null)} title="Link to a lower-quality host file?"> +

+ The slave has the better copy ({qualityLabel(sp)} vs {qualityLabel(hp)}). Linking replaces it with the host's{" "} + {qualityLabel(hp)} file. The slave's original goes to quarantine and can be restored until it is purged. +

+
+ + +
+
+ setConfirm(null)} title="Replace the host file with the slave copy?"> +

+ This changes the host library. The host file {m.rel_host} goes to quarantine, and the + slave's file ({qualityLabel(sp)}) is hardlinked into the host album in its place + {renamed && ( + <> + {" "}as {m.rel_slave.split("/").pop()} + + )} + . Both libraries then share one file. +

+
+ + +
+
+
+ ); +} + +function better(yes: boolean) { + return yes ? ★ better : null; +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx new file mode 100644 index 0000000..64c83f4 --- /dev/null +++ b/web/src/pages/Settings.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { useInfo } from "../App"; +import { Alert, Button, Card, Field, Toggle, inputCls } from "../ui"; +import { useToast } from "../toast"; + +export function SettingsPage() { + const info = useInfo(); + const qc = useQueryClient(); + const toast = useToast(); + const [cur, setCur] = useState(""); + const [next, setNext] = useState(""); + const dry = useMutation({ + mutationFn: (v: boolean) => api.put("/settings", { dry_run: v }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["info"] }), + }); + const pw = useMutation({ + mutationFn: () => api.post("/auth/password", { current: cur, new: next }), + onSuccess: () => { + setCur(""); + setNext(""); + toast("ok", "Password changed. Other sessions were signed out."); + }, + }); + + return ( +
+

Settings

+ + +

Safety

+ dry.mutate(v)} + label={Dry run} + /> +

+ While dry run is on, every link, promote, build and remove action is recorded in Activity as planned, and nothing on disk changes. + Scanning and comparing work normally. Turn it off once the planned actions look right. +

+
+
Always on:
+
    +
  • Files must be unchanged since the scan, or the action is refused.
  • +
  • Identical files are compared byte for byte again right before linking.
  • +
  • Replacements are staged as a hardlink and atomically renamed into place, so a file is never missing, even after a crash.
  • +
  • Every new link is checked by inode after it is created.
  • +
  • Non-identical originals are quarantined, not deleted.
  • +
  • Symlinks are never followed, and every path is confined to the allowed roots.
  • +
+
+
+ + +

Environment

+
+ Allowed roots: + {info.data?.allowed_roots.map((r) => ( + + {r} + + ))} +
+

+ Set with PLAYLINK_ALLOWED_ROOTS. Libraries and quarantine folders must be inside these roots. +

+
+ + +

Change password

+
{ + e.preventDefault(); + pw.mutate(); + }} + > + + setCur(e.target.value)} /> + + + setNext(e.target.value)} /> + + {pw.error && {pw.error.message}} + +
+
+
+ ); +} diff --git a/web/src/toast.tsx b/web/src/toast.tsx new file mode 100644 index 0000000..3ebeadb --- /dev/null +++ b/web/src/toast.tsx @@ -0,0 +1,36 @@ +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { CheckCircle2, AlertTriangle } from "lucide-react"; +import { cx } from "./ui"; + +type Toast = { id: number; tone: "ok" | "err"; text: string }; +const Ctx = createContext<(tone: Toast["tone"], text: string) => void>(() => {}); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + const push = useCallback((tone: Toast["tone"], text: string) => { + const id = Date.now() + Math.random(); + setItems((t) => [...t, { id, tone, text }]); + setTimeout(() => setItems((t) => t.filter((x) => x.id !== id)), tone === "err" ? 8000 : 4000); + }, []); + return ( + + {children} +
+ {items.map((t) => ( +
+ {t.tone === "ok" ? : } + {t.text} +
+ ))} +
+
+ ); +} + +export const useToast = () => useContext(Ctx); diff --git a/web/src/ui.tsx b/web/src/ui.tsx new file mode 100644 index 0000000..81c7988 --- /dev/null +++ b/web/src/ui.tsx @@ -0,0 +1,185 @@ +import { useEffect, type ButtonHTMLAttributes, type ReactNode } from "react"; +import { Loader2, X } from "lucide-react"; + +export function cx(...c: (string | false | null | undefined)[]) { + return c.filter(Boolean).join(" "); +} + +type Variant = "primary" | "secondary" | "ghost" | "danger" | "warn"; + +const variants: Record = { + primary: "bg-accent text-white hover:bg-violet-500", + secondary: "bg-panel-2 border border-line hover:bg-[#222633] text-gray-100", + ghost: "hover:bg-panel-2 text-gray-300", + danger: "bg-red-600/90 text-white hover:bg-red-500", + warn: "bg-amber-500/90 text-black hover:bg-amber-400", +}; + +export function Button({ + variant = "secondary", + loading, + className, + children, + size = "md", + ...props +}: ButtonHTMLAttributes & { variant?: Variant; loading?: boolean; size?: "sm" | "md" }) { + return ( + + ); +} + +export function Card({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +const tones = { + gray: "bg-gray-500/15 text-gray-300 border-gray-500/25", + green: "bg-emerald-500/15 text-emerald-300 border-emerald-500/25", + blue: "bg-sky-500/15 text-sky-300 border-sky-500/25", + amber: "bg-amber-500/15 text-amber-300 border-amber-500/25", + red: "bg-red-500/15 text-red-300 border-red-500/25", + violet: "bg-violet-500/15 text-violet-300 border-violet-500/25", +}; + +export function Badge({ tone = "gray", children, className }: { tone?: keyof typeof tones; children: ReactNode; className?: string }) { + return ( + + {children} + + ); +} + +export const statusTone: Record = { + linked: "green", + identical: "blue", + different: "amber", + similar: "violet", + unmatched: "gray", + error: "red", +}; + +export function Spinner({ className }: { className?: string }) { + return ; +} + +export function Modal({ + open, + onClose, + title, + children, + wide, +}: { + open: boolean; + onClose: () => void; + title: ReactNode; + children: ReactNode; + wide?: boolean; +}) { + useEffect(() => { + if (!open) return; + const h = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [open, onClose]); + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+
+
+ ); +} + +export function Field({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) { + return ( + + ); +} + +export const inputCls = + "w-full h-9 rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent focus:ring-1 focus:ring-accent placeholder:text-gray-600"; + +export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode }) { + return ( + + ); +} + +export function Alert({ tone, children, icon }: { tone: "amber" | "red" | "blue" | "green"; children: ReactNode; icon?: ReactNode }) { + const c = { + amber: "border-amber-500/30 bg-amber-500/10 text-amber-200", + red: "border-red-500/30 bg-red-500/10 text-red-200", + blue: "border-sky-500/30 bg-sky-500/10 text-sky-200", + green: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200", + }[tone]; + return
{icon}
{children}
; +} + +export function Empty({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }) { + return ( +
+
{icon}
+
{title}
+ {children &&
{children}
} +
+ ); +} + +export function formatBytes(n: number) { + if (!n) return "0 B"; + const u = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1); + return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`; +} + +export function formatAgo(unix: number | null | undefined) { + if (!unix) return "never"; + const s = Math.floor(Date.now() / 1000 - unix); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +export function formatDuration(sec: number) { + if (!sec) return "–"; + const m = Math.floor(sec / 60); + return `${m}:${String(Math.round(sec % 60)).padStart(2, "0")}`; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..41b358f --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..a8a38b9 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { outDir: "dist", emptyOutDir: true }, + server: { proxy: { "/api": "http://localhost:7480" } }, +});