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 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 15:38:25 -07:00
commit ac47e61b08
42 files changed
+8892

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.git
web/node_modules
web/dist
config
*.db
+9
View File
@@ -0,0 +1,9 @@
/playlink
/config/
*.db
*.db-wal
*.db-shm
web/node_modules/
web/dist/*
!web/dist/.gitkeep
web/*.tsbuildinfo
+31
View File
@@ -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"]
+18
View File
@@ -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 .
+129
View File
@@ -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 (`<slave>/.playlink-trash/<op-id>/…`). 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://<server>: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
```
+153
View File
@@ -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:
}
}
}
+22
View File
@@ -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
+21
View File
@@ -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
)
+54
View File
@@ -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=
+281
View File
@@ -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})
}
+561
View File
@@ -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})
}
+352
View File
@@ -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
}
+481
View File
@@ -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)
}
}
}
+834
View File
@@ -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
}
+448
View File
@@ -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)
}
+405
View File
@@ -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)
}
+219
View File
@@ -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
}
+57
View File
@@ -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)
}
}
+651
View File
@@ -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()
}
+162
View File
@@ -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"
}
View File
Whitespace-only changes.
+19
View File
@@ -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
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>playlink</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1949
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#7c3aed"/><path d="M12 22V9l11-2v13" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/><circle cx="9.5" cy="22" r="2.8" fill="#fff"/><circle cx="20.5" cy="20" r="2.8" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 332 B

+124
View File
@@ -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>("/info") });
}
export function App() {
const auth = useQuery({ queryKey: ["auth"], queryFn: () => api.get<AuthState>("/auth/state") });
if (auth.isLoading) {
return (
<div className="grid h-full place-items-center">
<Spinner />
</div>
);
}
if (!auth.data?.authenticated) return <Login setup={!!auth.data?.setup_required} />;
return <Shell username={auth.data.username ?? ""} />;
}
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 = <PairPage id={Number(route[1])} tab={route[2] ?? "review"} />;
else if (route[0] === "activity") page = <ActivityPage />;
else if (route[0] === "settings") page = <SettingsPage />;
else page = <Dashboard />;
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-40 border-b border-line bg-bg/85 backdrop-blur">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-4">
<a href="#/" className="flex items-center gap-2 font-semibold tracking-tight">
<img src="/favicon.svg" className="size-6" alt="" />
playlink
</a>
<nav className="flex items-center gap-1">
{nav.map((n) => (
<a
key={n.to}
href={`#${n.to}`}
className={cx(
"flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm",
n.active ? "bg-panel-2 text-white" : "text-muted hover:text-white",
)}
>
<n.icon className="size-4" />
<span className="hidden sm:inline">{n.label}</span>
</a>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setDry.mutate(!dry)}
title="Toggle dry run"
className={cx(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium cursor-pointer",
dry ? "border-sky-500/40 bg-sky-500/10 text-sky-300" : "border-amber-500/40 bg-amber-500/10 text-amber-300",
)}
>
{dry ? <FlaskConical className="size-3.5" /> : <ShieldCheck className="size-3.5" />}
{dry ? "Dry run" : "Live mode"}
</button>
<span className="hidden text-sm text-muted md:inline">{username}</span>
<button onClick={() => logout.mutate()} className="rounded-md p-1.5 text-muted hover:bg-panel-2 hover:text-white cursor-pointer" title="Sign out">
<LogOut className="size-4" />
</button>
</div>
</div>
</header>
{dry && (
<div className="border-b border-sky-500/20 bg-sky-500/5 text-center text-xs text-sky-200 py-1.5 px-4">
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.
</div>
)}
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6">{page}</main>
<footer className="py-4 text-center text-xs text-gray-600">playlink {info.data?.version}</footer>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
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: <T>(p: string) => request<T>("GET", p),
post: <T>(p: string, b: unknown = {}) => request<T>("POST", p, b),
put: <T>(p: string, b: unknown) => request<T>("PUT", p, b),
del: <T>(p: string) => request<T>("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<string, number>;
skipped: string[];
started_at: number;
finished_at?: number;
};
export type Summary = {
counts: Record<string, number>;
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<T> = { items: T[]; total: number };
+14
View File
@@ -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; }
+20
View File
@@ -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(
<StrictMode>
<QueryClientProvider client={qc}>
<ToastProvider>
<App />
</ToastProvider>
</QueryClientProvider>
</StrictMode>,
);
+181
View File
@@ -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 (
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
<p className="text-sm text-muted">Every change playlink makes is recorded here and can be undone.</p>
</div>
<div className="flex rounded-lg border border-line p-0.5">
{(["all", "quarantine"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", view === v ? "bg-panel-2 text-white" : "text-muted")}
>
{v === "all" ? "History" : "Quarantine"}
</button>
))}
</div>
</div>
<OpsTable quarantine={view === "quarantine"} />
</div>
);
}
const actionLabel: Record<Operation["action"], string> = {
link: "Linked slave → host",
promote: "Promoted slave → host",
build: "Built into slave",
remove: "Removed from slave",
};
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
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 | { op: Operation; kind: "undo" | "purge" }>(null);
const qc = useQueryClient();
const toast = useToast();
const pairs = useQuery({ queryKey: ["pairs"], queryFn: () => api.get<Pair[]>("/pairs") });
const names = Object.fromEntries((pairs.data ?? []).map((p) => [p.id, p.name]));
const ops = useQuery({
queryKey: ["ops", pairId, quarantine, page],
queryFn: () => api.get<Page<Operation>>(`/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 <Spinner />;
const items = ops.data?.items ?? [];
return (
<>
<Card className="overflow-hidden">
{items.length === 0 && (
<Empty icon={quarantine ? <Archive className="size-8" /> : <History className="size-8" />} title={quarantine ? "Quarantine is empty" : "No activity yet"}>
{quarantine ? "Originals replaced by a non-identical file show up here until they are purged." : undefined}
</Empty>
)}
{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 (
<div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0">
<div className="w-40 shrink-0">
<div className="text-sm">{actionLabel[o.action]}</div>
<div className="text-[11px] text-muted">
{formatAgo(o.created_at)} · {o.trigger}
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate mono text-[13px]" title={o.rel_slave}>
{o.rel_slave}
</div>
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_host && (
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
)}
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
{inQuarantine && (
<div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}>
{o.quarantine_side} original kept in quarantine
</div>
)}
</div>
<div className="flex items-center gap-1.5">
{o.bytes > 0 && o.status === "done" && !o.undone_at && <Badge tone="green">−{formatBytes(o.bytes)}</Badge>}
<Badge tone={statusTone[o.status]}>{o.dry_run ? "dry run" : o.status}</Badge>
{o.undone_at && <Badge>undone</Badge>}
{o.purged_at && <Badge>purged</Badge>}
</div>
<div className="flex gap-1.5">
{canUndo && (
<Button size="sm" onClick={() => setConfirm({ op: o, kind: "undo" })}>
<RotateCcw className="size-3.5" /> {o.quarantine_path ? "Restore" : "Undo"}
</Button>
)}
{inQuarantine && (
<Button size="sm" variant="ghost" onClick={() => setConfirm({ op: o, kind: "purge" })} title="Delete quarantined original">
<Trash2 className="size-3.5" />
</Button>
)}
</div>
</div>
);
})}
</Card>
{ops.data && ops.data.total > 50 && (
<div className="mt-3 flex justify-end gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * 50 >= ops.data.total} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
)}
<Modal open={!!confirm} onClose={() => setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}>
{confirm && (
<p className="text-sm text-gray-300">
{confirm.kind === "purge" ? (
<>
The quarantined {confirm.op.quarantine_side} original of <code className="mono break-all">{confirm.op.rel_slave}</code> will be deleted.{" "}
<b>This cannot be undone.</b>
</>
) : (
undoText(confirm.op)
)}
</p>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant={confirm?.kind === "purge" ? "danger" : "primary"} loading={act.isPending} onClick={() => confirm && act.mutate(confirm)}>
{confirm?.kind === "undo" ? "Undo" : "Delete forever"}
</Button>
</div>
</Modal>
</>
);
}
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.";
}
}
+136
View File
@@ -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<Set<string>>(new Set());
const [confirm, setConfirm] = useState<null | "build" | "remove">(null);
const qc = useQueryClient();
const toast = useToast();
const tree = useQuery({
queryKey: ["tree", pair.id, path],
queryFn: () => api.get<TreeEntry[]>(`/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 (
<div className="space-y-4">
<Alert tone="blue">
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 <code className="mono">Artist/Album/Title.ext</code> path. No extra space is used. Existing slave files are never
overwritten, and <b>Remove</b> only deletes slave files that are hardlinks of the host file, so the host copy is always kept.
</Alert>
<div className="flex flex-wrap items-center justify-between gap-3">
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
host
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="size-3.5 text-muted" />
<button className="truncate hover:underline cursor-pointer" onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}>
{c}
</button>
</span>
))}
</nav>
<div className="flex items-center gap-2">
{sel.size > 0 && <span className="text-xs text-muted">{sel.size} selected</span>}
<Button size="sm" disabled={!sel.size || running} onClick={() => setConfirm("remove")}>
<Unlink className="size-3.5" /> Remove from slave
</Button>
<Button size="sm" variant="primary" disabled={!sel.size || running} onClick={() => setConfirm("build")}>
<Link2 className="size-3.5" /> Link into slave
</Button>
</div>
</div>
<Card className="overflow-hidden">
{tree.isLoading && (
<div className="p-8">
<Spinner />
</div>
)}
{tree.error && <div className="p-4"><Alert tone="red">{tree.error.message}</Alert></div>}
{tree.data?.length === 0 && <Empty icon={<Disc3 className="size-8" />} title="Empty folder" />}
{all.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<input
type="checkbox"
className="accent-violet-500"
checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(all.map((e) => e.rel)))}
/>
Select all in this folder
</label>
)}
{all.map((e) => (
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
<input type="checkbox" className="accent-violet-500" checked={sel.has(e.rel)} onChange={() => toggle(e.rel)} />
{e.dir ? <Folder className="size-4 shrink-0 text-accent" /> : <FileAudio className="size-4 shrink-0 text-muted" />}
{e.dir ? (
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => { setPath(e.rel); setSel(new Set()); }}>
{e.name}
</button>
) : (
<span className="min-w-0 flex-1 truncate text-sm">{e.name}</span>
)}
{e.dir && !!e.linked && <Badge tone="green">{e.linked} linked</Badge>}
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
{e.state === "conflict" && <Badge tone="amber">different file in slave</Badge>}
{!e.dir && <span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>}
</div>
))}
</Card>
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
<Modal open={confirm !== null} onClose={() => setConfirm(null)} title={confirm === "build" ? "Link into slave library?" : "Remove from slave library?"}>
<p className="text-sm text-gray-300">
{confirm === "build" ? (
<>
Every audio file in the {sel.size} selected item(s) will be hardlinked into <code className="mono">{pair.slave_root}</code> 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 <b>hardlinks of the host file</b> will be deleted from{" "}
<code className="mono">{pair.slave_root}</code>. The host copy is kept. Any other slave file is left untouched.
</>
)}
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant={confirm === "build" ? "primary" : "danger"} onClick={() => confirm && run.mutate(confirm)} loading={run.isPending}>
{confirm === "build" ? "Link" : "Remove"}
</Button>
</div>
</Modal>
</div>
);
}
+116
View File
@@ -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<Pair[]>("/pairs"),
refetchInterval: (q) => (q.state.data?.some((p) => p.job?.state === "running") ? 1000 : 15000),
});
return (
<div className="space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Libraries</h1>
<p className="text-sm text-muted">Each pair links matching songs from a slave library to the host library's copy.</p>
</div>
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="size-4" /> New pair
</Button>
</div>
{pairs.isLoading && <Spinner />}
{pairs.data?.length === 0 && (
<Card>
<Empty icon={<Library className="size-10" />} title="No library pairs yet">
Create a pair with a <b>host</b> library (e.g. Albums) and a <b>slave</b> 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.
<div className="mt-4">
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="size-4" /> Create your first pair
</Button>
</div>
</Empty>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-2">
{pairs.data?.map((p) => <PairCard key={p.id} pair={p} />)}
</div>
<PairForm open={creating} onClose={() => setCreating(false)} />
</div>
);
}
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 (
<Card className="flex flex-col p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<a href={`#/pair/${pair.id}`} className="text-lg font-semibold hover:text-accent">
{pair.name}
</a>
<div className="mt-1 space-y-0.5 text-xs">
<div className="truncate">
<span className="text-muted">host</span> <code className="mono">{pair.host_root}</code>
</div>
<div className="truncate">
<span className="text-muted">slave</span> <code className="mono">{pair.slave_root}</code>
</div>
</div>
</div>
<ScanButton pair={pair} />
</div>
<div className="mt-4 grid grid-cols-4 gap-2">
{stats.map(([k, v]) => (
<a
key={k}
href={`#/pair/${pair.id}/review?status=${k}`}
className="rounded-lg border border-line bg-panel-2 px-3 py-2 hover:border-accent/50"
>
<div className="text-xl font-semibold tabular-nums">{v}</div>
<Badge tone={k === "review" ? "amber" : statusTone[k]}>{k}</Badge>
</a>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
<span>Reclaimable: <b className="text-sky-300">{formatBytes(pair.summary.reclaimable_bytes)}</b></span>
<span>Deduplicated: <b className="text-emerald-300">{formatBytes(pair.summary.linked_bytes)}</b></span>
<span>Last scan: {formatAgo(pair.last_scan_at)}</span>
{pair.auto_link_identical && <Badge tone="blue">auto-link</Badge>}
{pair.scan_interval_minutes > 0 && <Badge>every {pair.scan_interval_minutes}m</Badge>}
</div>
{pair.job?.state === "running" && <JobBar job={pair.job} className="mt-4" />}
{pair.last_scan_error && (
<div className="mt-3 flex gap-2 text-xs text-red-300">
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_error}
</div>
)}
{pair.last_scan_warnings.length > 0 && (
<div className="mt-3 flex gap-2 text-xs text-amber-300">
<AlertTriangle className="size-3.5 shrink-0" /> {pair.last_scan_warnings.length} layout warning{pair.last_scan_warnings.length > 1 && "s"}
</div>
)}
<a href={`#/pair/${pair.id}`} className="mt-4 flex items-center gap-1 self-end text-sm text-accent hover:underline">
Open <ArrowRight className="size-3.5" />
</a>
</Card>
);
}
+60
View File
@@ -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 (
<div className="grid min-h-full place-items-center p-4">
<Card className="w-full max-w-sm p-6">
<div className="mb-6 flex items-center gap-2.5">
<img src="/favicon.svg" className="size-8" alt="" />
<div>
<div className="text-lg font-semibold">playlink</div>
<div className="text-xs text-muted">{setup ? "Create your admin account" : "Sign in"}</div>
</div>
</div>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
m.mutate();
}}
>
<Field label="Username">
<input className={inputCls} autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</Field>
<Field label="Password" hint={setup ? "At least 10 characters." : undefined}>
<input
className={inputCls}
type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
{setup && (
<Field label="Confirm password">
<input className={inputCls} type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
</Field>
)}
{m.error && <Alert tone="red">{m.error.message}</Alert>}
<Button variant="primary" className="w-full" loading={m.isPending} type="submit">
{setup ? "Create account" : "Sign in"}
</Button>
</form>
</Card>
</div>
);
}
+221
View File
@@ -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<PairInput>(pair ?? blank);
const [exts, setExts] = useState((pair?.extensions ?? []).join(", "));
const [picker, setPicker] = useState<null | "host_root" | "slave_root" | "quarantine_dir">(null);
const [validation, setValidation] = useState<Validation | null>(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 extends keyof PairInput>(k: K, v: PairInput[K]) => {
setF((p) => ({ ...p, [k]: v }));
setValidation(null);
};
const validate = useMutation({
mutationFn: () => api.post<Validation>("/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 (
<Modal open={open} onClose={onClose} title={pair ? "Edit library pair" : "New library pair"} wide>
<div className="space-y-4">
<Field label="Name">
<input className={inputCls} value={f.name} onChange={(e) => set("name", e.target.value)} />
</Field>
<div className="grid gap-4 md:grid-cols-2">
<PathField
label="Host library (keeps the files)"
hint="Example: Albums. Its files are the source of every hardlink."
value={f.host_root}
onChange={(v) => set("host_root", v)}
onBrowse={() => setPicker("host_root")}
/>
<PathField
label="Slave library (gets hardlinks)"
hint="Example: your curated Singles library. Matching files here are replaced with hardlinks."
value={f.slave_root}
onChange={(v) => set("slave_root", v)}
onBrowse={() => setPicker("slave_root")}
/>
</div>
<Alert tone="blue">
Both libraries should use the same <code className="mono">Artist/Album/Title.ext</code> 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.
</Alert>
<PathField
label="Quarantine folder"
hint="Originals replaced by a non-identical file are moved here so you can restore them. Default: <slave>/.playlink-trash"
value={f.quarantine_dir}
placeholder="(default)"
onChange={(v) => set("quarantine_dir", v)}
onBrowse={() => setPicker("quarantine_dir")}
/>
<Field label="File extensions" hint="Comma-separated. Other files (covers, lyrics, playlists) are never touched.">
<input className={inputCls} value={exts} onChange={(e) => setExts(e.target.value)} />
</Field>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Scan interval (minutes)" hint="0 = manual only, otherwise at least 5">
<input className={inputCls} type="number" min={0} value={f.scan_interval_minutes} onChange={(e) => set("scan_interval_minutes", Number(e.target.value))} />
</Field>
<Field label="Quarantine retention (days)" hint="0 = keep until purged manually">
<input className={inputCls} type="number" min={0} value={f.retention_days} onChange={(e) => set("retention_days", Number(e.target.value))} />
</Field>
<Field label="Settle time (minutes)" hint="Auto-link skips files modified more recently than this">
<input className={inputCls} type="number" min={0} value={f.settle_minutes} onChange={(e) => set("settle_minutes", Number(e.target.value))} />
</Field>
</div>
<div className="rounded-lg border border-line bg-panel-2 p-3.5">
<Toggle
checked={f.auto_link_identical}
onChange={(v) => set("auto_link_identical", v)}
label={<span className="font-medium">Automatically link byte-identical files after each scan</span>}
/>
<p className="mt-1.5 pl-11.5 text-xs text-muted">
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.
</p>
</div>
{validation && !validation.ok && <Alert tone="red">{validation.error}</Alert>}
{validation?.ok && validation.warnings.length === 0 && <Alert tone="green">Looks good. Both folders are valid and on the same filesystem.</Alert>}
{validation?.warnings.map((w) => (
<Alert key={w} tone="amber" icon={<AlertTriangle className="size-4 shrink-0 mt-0.5" />}>
{w}
</Alert>
))}
{save.error && <Alert tone="red">{save.error.message}</Alert>}
<div className="flex justify-end gap-2 pt-2">
<Button onClick={onClose} variant="ghost">
Cancel
</Button>
<Button onClick={() => validate.mutate()} loading={validate.isPending}>
Validate
</Button>
<Button variant="primary" onClick={() => save.mutate()} loading={save.isPending}>
{pair ? "Save" : "Create"}
</Button>
</div>
</div>
{picker && (
<FolderPicker
start={f[picker] || ""}
onPick={(p) => {
set(picker, p);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
</Modal>
);
}
function PathField(props: { label: string; hint: string; value: string; placeholder?: string; onChange: (v: string) => void; onBrowse: () => void }) {
return (
<Field label={props.label} hint={props.hint}>
<div className="flex gap-2">
<input className={inputCls + " mono text-xs"} value={props.value} placeholder={props.placeholder ?? "/data/..."} onChange={(e) => props.onChange(e.target.value)} />
<Button type="button" onClick={props.onBrowse} title="Browse">
<FolderOpen className="size-4" />
</Button>
</div>
</Field>
);
}
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 (
<Modal open onClose={onClose} title="Choose folder">
<div className="mb-3 flex items-center gap-2">
<Button size="sm" disabled={!path} onClick={() => setPath(q.data?.parent ?? "")}>
<ChevronLeft className="size-3.5" />
</Button>
<code className="mono truncate text-xs text-muted">{q.data?.path || "Allowed roots"}</code>
</div>
{q.error && (
<Alert tone="red">
{q.error.message}{" "}
<button className="underline cursor-pointer" onClick={() => setPath("")}>
Back to roots
</button>
</Alert>
)}
<div className="max-h-80 overflow-y-auto rounded-lg border border-line">
{q.data?.dirs.map((d) => (
<button key={d} onClick={() => setPath(d)} className="flex w-full items-center gap-2 border-b border-line px-3 py-2 text-left text-sm last:border-0 hover:bg-panel-2 cursor-pointer">
<Folder className="size-4 text-accent" />
<span className="truncate">{q.data.path ? d.split("/").pop() : d}</span>
</button>
))}
{q.data && q.data.dirs.length === 0 && <div className="p-4 text-center text-sm text-muted">No subfolders</div>}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!q.data?.path} onClick={() => onPick(q.data!.path)}>
Use this folder
</Button>
</div>
</Modal>
);
}
+221
View File
@@ -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<Pair>(`/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<string | undefined>(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 (
<Button size="sm" onClick={() => cancel.mutate()} loading={cancel.isPending}>
<Square className="size-3" /> Stop
</Button>
);
return (
<Button size="sm" variant="primary" onClick={() => scan.mutate()} loading={scan.isPending}>
<RefreshCw className="size-3.5" /> Scan
</Button>
);
}
export function JobBar({ job, className }: { job: Job; className?: string }) {
const pct = job.total > 0 ? Math.round((job.done / job.total) * 100) : null;
return (
<div className={cx("space-y-1.5", className)}>
<div className="flex justify-between text-xs">
<span className="flex items-center gap-1.5 text-gray-300">
<Spinner className="size-3" /> {job.phase || job.kind}
</span>
<span className="tabular-nums text-muted">{job.total > 0 ? `${job.done} / ${job.total}` : "…"}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-line">
<div className={cx("h-full bg-accent transition-all", pct === null && "w-1/3 animate-pulse")} style={pct !== null ? { width: `${pct}%` } : undefined} />
</div>
</div>
);
}
function LastJob({ job }: { job: Job }) {
const [open, setOpen] = useState(false);
if (job.state === "running") return <JobBar job={job} />;
const results = Object.entries(job.results).filter(([, v]) => v > 0);
return (
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge tone={job.state === "done" ? "green" : "red"}>{job.kind}: {job.state}</Badge>
<span className="text-muted">{job.message || job.error}</span>
{results.map(([k, v]) => (
<Badge key={k}>
{k} {v}
</Badge>
))}
{(job.skipped?.length ?? 0) > 0 && (
<button className="text-amber-300 underline cursor-pointer" onClick={() => setOpen(true)}>
view skipped
</button>
)}
<Modal open={open} onClose={() => setOpen(false)} title="Skipped items" wide>
<ul className="max-h-[60vh] space-y-1 overflow-y-auto text-xs mono">
{(job.skipped ?? []).map((s, i) => (
<li key={i} className="border-b border-line pb-1">
{s}
</li>
))}
</ul>
</Modal>
</div>
);
}
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 <Spinner />;
if (q.error || !q.data) return <Alert tone="red">{q.error?.message ?? "Not found"}</Alert>;
const pair = q.data;
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<a href="#/" className="mb-1 inline-flex items-center gap-1 text-xs text-muted hover:text-white">
<ChevronLeft className="size-3.5" /> Libraries
</a>
<h1 className="text-2xl font-semibold tracking-tight">{pair.name}</h1>
<div className="mt-1 flex flex-col gap-0.5 text-xs sm:flex-row sm:gap-4">
<span className="truncate">
<Badge tone="violet">host</Badge> <code className="mono">{pair.host_root}</code>
</span>
<span className="truncate">
<Badge>slave</Badge> <code className="mono">{pair.slave_root}</code>
</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Scanned {formatAgo(pair.last_scan_at)}</span>
<Button size="sm" onClick={() => setEditing(true)}>
<Pencil className="size-3.5" /> Edit
</Button>
<Button size="sm" variant="ghost" onClick={() => setDeleting(true)} title="Remove pair">
<Trash2 className="size-3.5" />
</Button>
<ScanButton pair={pair} />
</div>
</div>
{pair.job && (
<Card className="px-4 py-3">
<LastJob job={pair.job} />
</Card>
)}
{pair.last_scan_error && <Alert tone="red">Last scan failed: {pair.last_scan_error}</Alert>}
{pair.last_scan_warnings.map((w) => (
<Alert key={w} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{w}
</Alert>
))}
<div className="flex gap-1 border-b border-line">
{tabs.map((t) => (
<a
key={t.id}
href={`#/pair/${id}/${t.id}`}
className={cx("-mb-px border-b-2 px-3.5 py-2 text-sm", tab === t.id ? "border-accent text-white" : "border-transparent text-muted hover:text-white")}
>
{t.label}
{t.id === "review" && pair.summary.review > 0 && (
<Badge tone="amber" className="ml-1.5">
{pair.summary.review}
</Badge>
)}
</a>
))}
</div>
{tab === "review" && <Review pair={pair} />}
{tab === "build" && <Build pair={pair} />}
{tab === "history" && <OpsTable pairId={pair.id} />}
<PairForm pair={pair} open={editing} onClose={() => setEditing(false)} />
<Modal open={deleting} onClose={() => setDeleting(false)} title="Remove library pair?">
<p className="text-sm text-gray-300">
This deletes the pair's configuration, scan results and history from playlink. <b>No files on disk are changed</b>, and existing
hardlinks stay in place. Quarantined files stay in <code className="mono">{pair.quarantine_dir}</code>, but you can no longer restore them from the UI.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDeleting(false)}>
Cancel
</Button>
<Button variant="danger" onClick={() => del.mutate()} loading={del.isPending}>
Remove pair
</Button>
</div>
</Modal>
</div>
);
}
+398
View File
@@ -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<Match["kind"], string> = {
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<Page<Match>>(`/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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{filters.map((f) => (
<button
key={f.id}
onClick={() => setStatus(f.id)}
className={cx(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs cursor-pointer",
status === f.id ? "border-accent bg-accent/15 text-white" : "border-line text-muted hover:text-white",
)}
>
{f.label}
{count(f.id) !== undefined && <span className="tabular-nums opacity-70">{count(f.id)}</span>}
</button>
))}
<div className="relative ml-auto w-full sm:w-64">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
</div>
{status === "identical" && identical > 0 && (
<Card className="flex flex-wrap items-center justify-between gap-3 p-4">
<div className="text-sm">
<b>{identical}</b> byte-identical files ({formatBytes(pair.summary.reclaimable_bytes)}) can be replaced with hardlinks.
<div className="text-xs text-muted">Each one is compared byte for byte again right before linking. No quarantine is needed because the data stays in the host.</div>
</div>
<Button variant="primary" onClick={() => setConfirmBulk(true)} disabled={running}>
<CopyCheck className="size-4" /> Link all identical
</Button>
</Card>
)}
<Card className="overflow-hidden">
{list.isLoading && (
<div className="p-8">
<Spinner />
</div>
)}
{list.data?.items.length === 0 && (
<Empty icon={<CopyCheck className="size-8" />} 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."}
</Empty>
)}
{list.data?.items.map((m) => <MatchRow key={m.id} m={m} />)}
</Card>
{list.data && list.data.total > PAGE && (
<div className="flex items-center justify-between text-sm text-muted">
<span>
{page * PAGE + 1}–{Math.min((page + 1) * PAGE, list.data.total)} of {list.data.total}
</span>
<div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * PAGE >= list.data.total} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</div>
)}
<Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?">
<p className="text-sm text-gray-300">
Each of the <b>{identical}</b> 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.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirmBulk(false)}>
Cancel
</Button>
<Button variant="primary" onClick={() => bulk.mutate()} loading={bulk.isPending}>
Link {identical} files
</Button>
</div>
</Modal>
</div>
);
}
function PathLine({ m }: { m: Match }) {
const same = m.rel_host === m.rel_slave || !m.rel_host;
return (
<div className="min-w-0 text-sm">
<div className="truncate mono text-[13px]" title={m.rel_slave}>
{m.rel_slave}
</div>
{!same && (
<div className="flex min-w-0 items-center gap-1 text-xs text-muted" title={m.rel_host}>
<ArrowRight className="size-3 shrink-0" /> <span className="truncate mono">{m.rel_host}</span>
</div>
)}
</div>
);
}
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 (
<div className="border-b border-line last:border-0">
<div className="flex items-center gap-3 px-4 py-2.5">
<button
className={cx("text-muted", reviewable ? "cursor-pointer hover:text-white" : "invisible")}
onClick={() => setOpen(!open)}
aria-label="Expand"
>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
<div className="flex w-28 shrink-0 flex-col items-start gap-1">
<Badge tone={statusTone[m.status]}>{m.status}</Badge>
{m.kind !== "path" && m.kind !== "none" && <span className="text-[10px] text-muted">{kindLabel[m.kind]}</span>}
</div>
<div className="min-w-0 flex-1" onClick={() => reviewable && setOpen(!open)}>
<PathLine m={m} />
{m.note && <div className="text-xs text-red-300">{m.note}</div>}
</div>
{reviewable && (
<div className="hidden items-center gap-1.5 text-xs md:flex">
<span className="text-muted">{qualityLabel(m.host_probe)}</span>
<span className="text-gray-600">vs</span>
<span className="text-muted">{qualityLabel(m.slave_probe)}</span>
{better === "slave" && (
<Badge tone="amber">
<AlertTriangle className="size-3" /> host lower
</Badge>
)}
{better === "host" && <Badge tone="green">host better</Badge>}
{better === "equal" && <Badge>equal</Badge>}
</div>
)}
<div className="hidden w-20 text-right text-xs tabular-nums text-muted sm:block">{formatBytes(m.slave_size)}</div>
<div className="flex shrink-0 gap-1.5">
{m.status === "identical" && !m.ignored && (
<Button size="sm" variant="primary" onClick={() => link.mutate()} loading={link.isPending}>
<Link2 className="size-3.5" /> Link
</Button>
)}
{reviewable && !m.ignored && (
<Button size="sm" onClick={() => setOpen(!open)}>
Review
</Button>
)}
{(reviewable || m.status === "identical") && (
<Button size="sm" variant="ghost" onClick={() => ignore.mutate()} title={m.ignored ? "Un-ignore" : "Ignore"}>
{m.ignored ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
</Button>
)}
</div>
</div>
{open && reviewable && <ComparePanel m={m} onDone={refresh} />}
</div>
);
}
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 | "downgrade" | "promote">(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 (
<div className="border-t border-line bg-panel-2/50 px-4 py-4 md:pl-14">
{(!hp || !sp) && <Alert tone="amber">No codec details available (ffprobe missing or failed). Compare the files yourself before linking.</Alert>}
{hp && sp && (
<div className="overflow-x-auto">
<table className="w-full max-w-2xl text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1 pr-4 font-normal"></th>
<th className="py-1 pr-4 font-medium">
<Badge tone="violet">host</Badge> {better(v?.better === "host")}
</th>
<th className="py-1 font-medium">
<Badge>slave</Badge> {better(v?.better === "slave")}
</th>
</tr>
</thead>
<tbody>
{rows.map(([label, f]) => {
const a = hp.error ? "–" : f(hp, m.host_size);
const b = sp.error ? "–" : f(sp, m.slave_size);
return (
<tr key={label} className="border-t border-line/60">
<td className="py-1 pr-4 text-xs text-muted">{label}</td>
<td className={cx("py-1 pr-4", a !== b && "text-white")}>{a}</td>
<td className={cx("py-1", a !== b && "text-white")}>{b}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="mt-3 space-y-2">
{v?.notes?.map((n) => (
<Alert key={n} tone="amber" icon={<AlertTriangle className="mt-0.5 size-4 shrink-0" />}>
{n}
</Alert>
))}
{v?.better === "slave" && (
<Alert tone="amber" icon={<Crown className="mt-0.5 size-4 shrink-0" />}>
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.
</Alert>
)}
{renamed && (
<Alert tone="blue">
File names differ. Linking puts <code className="mono">{m.rel_host.split("/").pop()}</code> into the slave folder in place of{" "}
<code className="mono">{m.rel_slave.split("/").pop()}</code>, so playlists that point at the old name will need updating.
</Alert>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="primary" onClick={() => (v?.better === "slave" ? setConfirm("downgrade") : link.mutate(false))} loading={link.isPending && !confirm}>
<Link2 className="size-4" /> Link to host copy
</Button>
<Button variant={v?.better === "slave" ? "warn" : "secondary"} onClick={() => setConfirm("promote")}>
<Crown className="size-4" /> Use slave copy as host
</Button>
</div>
<p className="mt-2 text-xs text-muted">Whatever gets replaced is kept in quarantine and can be restored from History.</p>
<Modal open={confirm === "downgrade"} onClose={() => setConfirm(null)} title="Link to a lower-quality host file?">
<p className="text-sm text-gray-300">
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.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant="warn" onClick={() => link.mutate(true)} loading={link.isPending}>
Link anyway
</Button>
</div>
</Modal>
<Modal open={confirm === "promote"} onClose={() => setConfirm(null)} title="Replace the host file with the slave copy?">
<p className="text-sm text-gray-300">
This <b>changes the host library</b>. The host file <code className="mono break-all">{m.rel_host}</code> goes to quarantine, and the
slave's file (<code className="mono">{qualityLabel(sp)}</code>) is hardlinked into the host album in its place
{renamed && (
<>
{" "}as <code className="mono">{m.rel_slave.split("/").pop()}</code>
</>
)}
. Both libraries then share one file.
</p>
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button variant="warn" onClick={() => promote.mutate()} loading={promote.isPending}>
Promote slave copy
</Button>
</div>
</Modal>
</div>
);
}
function better(yes: boolean) {
return yes ? <span className="text-[11px] text-emerald-300">★ better</span> : null;
}
+93
View File
@@ -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 (
<div className="max-w-2xl space-y-5">
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<Card className="space-y-3 p-5">
<h2 className="font-semibold">Safety</h2>
<Toggle
checked={info.data?.dry_run ?? true}
onChange={(v) => dry.mutate(v)}
label={<span className="font-medium">Dry run</span>}
/>
<p className="text-sm text-muted">
While dry run is on, every link, promote, build and remove action is recorded in Activity as <i>planned</i>, and nothing on disk changes.
Scanning and comparing work normally. Turn it off once the planned actions look right.
</p>
<div className="text-xs text-muted">
<div className="mb-1 font-medium text-gray-300">Always on:</div>
<ul className="list-disc space-y-0.5 pl-5">
<li>Files must be unchanged since the scan, or the action is refused.</li>
<li>Identical files are compared byte for byte again right before linking.</li>
<li>Replacements are staged as a hardlink and atomically renamed into place, so a file is never missing, even after a crash.</li>
<li>Every new link is checked by inode after it is created.</li>
<li>Non-identical originals are quarantined, not deleted.</li>
<li>Symlinks are never followed, and every path is confined to the allowed roots.</li>
</ul>
</div>
</Card>
<Card className="space-y-2 p-5">
<h2 className="font-semibold">Environment</h2>
<div className="text-sm">
<span className="text-muted">Allowed roots: </span>
{info.data?.allowed_roots.map((r) => (
<code key={r} className="mono mr-2 rounded bg-panel-2 px-1.5 py-0.5 text-xs">
{r}
</code>
))}
</div>
<p className="text-xs text-muted">
Set with <code className="mono">PLAYLINK_ALLOWED_ROOTS</code>. Libraries and quarantine folders must be inside these roots.
</p>
</Card>
<Card className="p-5">
<h2 className="mb-3 font-semibold">Change password</h2>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
pw.mutate();
}}
>
<Field label="Current password">
<input className={inputCls} type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} />
</Field>
<Field label="New password" hint="At least 10 characters">
<input className={inputCls} type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} />
</Field>
{pw.error && <Alert tone="red">{pw.error.message}</Alert>}
<Button type="submit" loading={pw.isPending}>
Update password
</Button>
</form>
</Card>
</div>
);
}
+36
View File
@@ -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<Toast[]>([]);
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 (
<Ctx.Provider value={push}>
{children}
<div className="fixed bottom-4 right-4 z-[60] flex w-96 max-w-[calc(100vw-2rem)] flex-col gap-2">
{items.map((t) => (
<div
key={t.id}
className={cx(
"flex items-start gap-2 rounded-lg border px-3.5 py-2.5 text-sm shadow-xl",
t.tone === "ok" ? "border-emerald-500/30 bg-[#0f1f19] text-emerald-100" : "border-red-500/30 bg-[#241314] text-red-100",
)}
>
{t.tone === "ok" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0" /> : <AlertTriangle className="mt-0.5 size-4 shrink-0" />}
<span className="break-words">{t.text}</span>
</div>
))}
</div>
</Ctx.Provider>
);
}
export const useToast = () => useContext(Ctx);
+185
View File
@@ -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<Variant, string> = {
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<HTMLButtonElement> & { variant?: Variant; loading?: boolean; size?: "sm" | "md" }) {
return (
<button
{...props}
disabled={props.disabled || loading}
className={cx(
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer whitespace-nowrap",
size === "sm" ? "h-7 px-2.5 text-xs" : "h-9 px-3.5 text-sm",
variants[variant],
className,
)}
>
{loading && <Loader2 className="size-3.5 animate-spin" />}
{children}
</button>
);
}
export function Card({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cx("rounded-xl border border-line bg-panel", className)}>{children}</div>;
}
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 (
<span className={cx("inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium", tones[tone], className)}>
{children}
</span>
);
}
export const statusTone: Record<string, keyof typeof tones> = {
linked: "green",
identical: "blue",
different: "amber",
similar: "violet",
unmatched: "gray",
error: "red",
};
export function Spinner({ className }: { className?: string }) {
return <Loader2 className={cx("animate-spin text-muted", className ?? "size-5")} />;
}
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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[8vh]" onMouseDown={onClose}>
<div
className={cx("w-full rounded-xl border border-line bg-panel shadow-2xl", wide ? "max-w-3xl" : "max-w-lg")}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-line px-5 py-3.5">
<h2 className="font-semibold">{title}</h2>
<button onClick={onClose} className="text-muted hover:text-white cursor-pointer" aria-label="Close">
<X className="size-4" />
</button>
</div>
<div className="p-5">{children}</div>
</div>
</div>
);
}
export function Field({ label, hint, children }: { label: string; hint?: ReactNode; children: ReactNode }) {
return (
<label className="block space-y-1.5">
<span className="text-sm font-medium text-gray-200">{label}</span>
{children}
{hint && <span className="block text-xs text-muted">{hint}</span>}
</label>
);
}
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 (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="inline-flex items-center gap-2.5 text-sm cursor-pointer"
>
<span className={cx("relative h-5 w-9 rounded-full transition-colors", checked ? "bg-accent" : "bg-line")}>
<span className={cx("absolute top-0.5 size-4 rounded-full bg-white transition-all", checked ? "left-4.5" : "left-0.5")} />
</span>
{label}
</button>
);
}
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 <div className={cx("flex gap-2.5 rounded-lg border px-3.5 py-2.5 text-sm", c)}>{icon}<div className="min-w-0 flex-1">{children}</div></div>;
}
export function Empty({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<div className="text-muted">{icon}</div>
<div className="font-medium">{title}</div>
{children && <div className="max-w-md text-sm text-muted">{children}</div>}
</div>
);
}
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")}`;
}
+17
View File
@@ -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"]
}
+9
View File
@@ -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" } },
});