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:
commit
ac47e61b08
42 files changed
+8892
No files matched your search
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
web/node_modules
|
||||
web/dist
|
||||
config
|
||||
*.db
|
||||
@@ -0,0 +1,9 @@
|
||||
/playlink
|
||||
/config/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
web/node_modules/
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
web/*.tsbuildinfo
|
||||
+31
@@ -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"]
|
||||
@@ -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 .
|
||||
@@ -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
|
||||
```
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
Whitespace-only changes.
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+1949
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
@@ -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; }
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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")}`;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" } },
|
||||
});
|
||||
Reference in new issue
Block a user