diff --git a/.env.example b/.env.example index 09c9db6..b2c943e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,23 @@ # libnovel scraper — environment overrides # Copy to .env and adjust values; do NOT commit this file with real secrets. +# ── Docker BuildKit ─────────────────────────────────────────────────────────── +# Required for the backend/Dockerfile cache mounts (--mount=type=cache). +# BuildKit is the default in Docker Engine 23+, but Colima users may need this. +# +# If you see: "the --mount option requires BuildKit", enable it one of two ways: +# +# Option A — per-project (recommended, zero restart needed): +# Uncomment the line below and copy this file to .env. +# Docker Compose reads .env automatically, so BuildKit will be active for +# every `docker compose build` / `docker compose up --build` in this project. +# +# Option B — system-wide for Colima (persists across restarts): +# echo '{"features":{"buildkit":true}}' > ~/.colima/default/daemon.json +# colima stop && colima start +# +# DOCKER_BUILDKIT=1 + # ── Service ports (host-side) ───────────────────────────────────────────────── # Port the scraper HTTP API listens on (default 8080) SCRAPER_PORT=8080 diff --git a/.gitea/workflows/release-v2.yaml b/.gitea/workflows/release-v2.yaml new file mode 100644 index 0000000..b11a450 --- /dev/null +++ b/.gitea/workflows/release-v2.yaml @@ -0,0 +1,163 @@ +name: Release / v2 + +on: + push: + tags: + - "v*" + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + cancel-in-progress: true + +jobs: + # ── backend: lint & test ───────────────────────────────────────────────────── + test-backend: + name: Test backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: go vet + working-directory: backend + run: go vet ./... + + - name: Run tests + working-directory: backend + run: go test -short -race -count=1 -timeout=60s ./... + + # ── ui-v2: type-check & build ──────────────────────────────────────────────── + build-ui: + name: Build ui-v2 + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui-v2 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui-v2/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Build + run: npm run build + + # ── docker: backend ────────────────────────────────────────────────────────── + docker-backend: + name: Docker / backend + runs-on: ubuntu-latest + needs: [test-backend] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-backend + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: backend + target: backend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} + + # ── docker: runner ─────────────────────────────────────────────────────────── + docker-runner: + name: Docker / runner + runs-on: ubuntu-latest + needs: [test-backend] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-runner + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: backend + target: runner + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ gitea.sha }} + + # ── docker: ui-v2 ──────────────────────────────────────────────────────────── + docker-ui: + name: Docker / ui-v2 + runs-on: ubuntu-latest + needs: [build-ui] + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USER }}/libnovel-ui-v2 + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ui-v2 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.version }} + BUILD_COMMIT=${{ gitea.sha }} diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..d32a9c3 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +# Exclude compiled binaries +bin/ + +# Exclude test binaries produced by `go test -c` +*.test + +# Git history is not needed inside the image +.git/ + +# Editor/OS noise +.DS_Store +*.swp +*.swo diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b26e4ef --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.26.1-alpine AS builder +WORKDIR /app + +# Download modules into the BuildKit cache so they survive across builds. +# This layer is only invalidated when go.mod or go.sum changes. +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/go/pkg/mod \ + go mod download + +COPY . . + +ARG VERSION=dev +ARG COMMIT=unknown + +# Build all three binaries in a single layer so the Go compiler can reuse +# intermediate object files. Both cache mounts are preserved between builds: +# /root/go/pkg/mod — downloaded module source +# /root/.cache/go-build — compiled package objects (incremental recompile) +RUN --mount=type=cache,target=/root/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /out/backend ./cmd/backend && \ + CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /out/runner ./cmd/runner && \ + CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o /out/healthcheck ./cmd/healthcheck + +# ── backend service ────────────────────────────────────────────────────────── +FROM gcr.io/distroless/static:nonroot AS backend +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder /out/backend /backend +ENTRYPOINT ["/backend"] + +# ── runner service ─────────────────────────────────────────────────────────── +FROM gcr.io/distroless/static:nonroot AS runner +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder /out/runner /runner +ENTRYPOINT ["/runner"] diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go new file mode 100644 index 0000000..fa95fcb --- /dev/null +++ b/backend/cmd/backend/main.go @@ -0,0 +1,125 @@ +// Command backend is the LibNovel HTTP API server. +// +// It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads, +// scrape-task creation, presigned MinIO URLs, audio-task creation, reading +// progress, live novelfire.net browse/search, and Kokoro voice list. +// +// All heavy lifting (scraping, TTS generation) is delegated to the runner +// binary via PocketBase task records. The backend never scrapes directly. +// +// Usage: +// +// backend # start HTTP server (blocks until SIGINT/SIGTERM) +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/libnovel/backend/internal/backend" + "github.com/libnovel/backend/internal/config" + "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/storage" +) + +// version and commit are set at build time via -ldflags. +var ( + version = "dev" + commit = "unknown" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "backend: fatal: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg := config.Load() + + // ── Logger ─────────────────────────────────────────────────────────────── + log := buildLogger(cfg.LogLevel) + log.Info("backend starting", + "version", version, + "commit", commit, + "addr", cfg.HTTP.Addr, + ) + + // ── Context: cancel on SIGINT / SIGTERM ────────────────────────────────── + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // ── Storage ────────────────────────────────────────────────────────────── + store, err := storage.NewStore(ctx, cfg, log) + if err != nil { + return fmt.Errorf("init storage: %w", err) + } + + // ── Kokoro (voice list only; audio generation is done by the runner) ───── + var kokoroClient kokoro.Client + if cfg.Kokoro.URL != "" { + kokoroClient = kokoro.New(cfg.Kokoro.URL) + log.Info("kokoro voices enabled", "url", cfg.Kokoro.URL) + } else { + log.Info("KOKORO_URL not set — voice list will use built-in fallback") + kokoroClient = &noopKokoro{} + } + + // ── Backend server ─────────────────────────────────────────────────────── + srv := backend.New( + backend.Config{ + Addr: cfg.HTTP.Addr, + DefaultVoice: cfg.Kokoro.DefaultVoice, + Version: version, + Commit: commit, + }, + backend.Dependencies{ + BookReader: store, + RankingStore: store, + AudioStore: store, + PresignStore: store, + ProgressStore: store, + Producer: store, + TaskReader: store, + Kokoro: kokoroClient, + Log: log, + }, + ) + + return srv.ListenAndServe(ctx) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func buildLogger(level string) *slog.Logger { + var lvl slog.Level + switch level { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) +} + +// noopKokoro is a no-op implementation used when KOKORO_URL is not set. +// The backend only uses Kokoro for the voice list; audio generation is the +// runner's responsibility. With no URL the built-in fallback list is served. +type noopKokoro struct{} + +func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) { + return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)") +} + +func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) { + return nil, nil +} diff --git a/backend/cmd/backend/main_test.go b/backend/cmd/backend/main_test.go new file mode 100644 index 0000000..edd47dc --- /dev/null +++ b/backend/cmd/backend/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "testing" +) + +// TestBuildLogger verifies that buildLogger returns a non-nil logger for each +// supported log level string and for unknown values. +func TestBuildLogger(t *testing.T) { + for _, level := range []string{"debug", "info", "warn", "error", "unknown", ""} { + l := buildLogger(level) + if l == nil { + t.Errorf("buildLogger(%q) returned nil", level) + } + } +} + +// TestNoopKokoro verifies that the no-op Kokoro stub returns the expected +// sentinel error from GenerateAudio and nil, nil from ListVoices. +func TestNoopKokoro(t *testing.T) { + noop := &noopKokoro{} + + _, err := noop.GenerateAudio(t.Context(), "text", "af_bella") + if err == nil { + t.Fatal("noopKokoro.GenerateAudio: expected error, got nil") + } + + voices, err := noop.ListVoices(t.Context()) + if err != nil { + t.Fatalf("noopKokoro.ListVoices: unexpected error: %v", err) + } + if voices != nil { + t.Fatalf("noopKokoro.ListVoices: expected nil slice, got %v", voices) + } +} + +// TestRunStorageUnreachable verifies that run() fails fast and returns a +// descriptive error when PocketBase is unreachable. +func TestRunStorageUnreachable(t *testing.T) { + // Point at an address nothing is listening on. + t.Setenv("POCKETBASE_URL", "http://127.0.0.1:19999") + // Use a fast listen address so we don't accidentally start a real server. + t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:0") + + err := run() + if err == nil { + t.Fatal("run() should have returned an error when storage is unreachable") + } + + t.Logf("got expected error: %v", err) +} + +// TestMain runs the test suite. No special setup required. +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/backend/cmd/healthcheck/main.go b/backend/cmd/healthcheck/main.go new file mode 100644 index 0000000..4b7aa20 --- /dev/null +++ b/backend/cmd/healthcheck/main.go @@ -0,0 +1,89 @@ +// healthcheck is a static binary used by Docker HEALTHCHECK CMD in distroless +// images (which have no shell, wget, or curl). +// +// Two modes: +// +// 1. HTTP mode (default): +// /healthcheck +// Performs GET ; exits 0 if HTTP 2xx/3xx, 1 otherwise. +// Example: /healthcheck http://localhost:8080/health +// +// 2. File-liveness mode: +// /healthcheck file +// Reads , parses its content as RFC3339 timestamp, and exits 1 if the +// timestamp is older than . Used by the runner service which +// writes /tmp/runner.alive on every successful poll. +// Example: /healthcheck file /tmp/runner.alive 120 +package main + +import ( + "fmt" + "net/http" + "os" + "strconv" + "time" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "file" { + checkFile() + return + } + checkHTTP() +} + +// checkHTTP performs a GET request and exits 0 on success, 1 on failure. +func checkHTTP() { + url := "http://localhost:8080/health" + if len(os.Args) > 1 { + url = os.Args[1] + } + resp, err := http.Get(url) //nolint:gosec,noctx + if err != nil { + fmt.Fprintf(os.Stderr, "healthcheck: %v\n", err) + os.Exit(1) + } + resp.Body.Close() + if resp.StatusCode >= 400 { + fmt.Fprintf(os.Stderr, "healthcheck: status %d\n", resp.StatusCode) + os.Exit(1) + } +} + +// checkFile reads a timestamp from a file and exits 1 if it is older than the +// given max age. Usage: /healthcheck file +func checkFile() { + if len(os.Args) < 4 { + fmt.Fprintln(os.Stderr, "healthcheck file: usage: /healthcheck file ") + os.Exit(1) + } + path := os.Args[2] + maxAgeSec, err := strconv.ParseInt(os.Args[3], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "healthcheck file: invalid max_age_seconds %q: %v\n", os.Args[3], err) + os.Exit(1) + } + + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "healthcheck file: cannot read %s: %v\n", path, err) + os.Exit(1) + } + + ts, err := time.Parse(time.RFC3339, string(data)) + if err != nil { + // Fallback: use file mtime if content is not a valid timestamp. + info, statErr := os.Stat(path) + if statErr != nil { + fmt.Fprintf(os.Stderr, "healthcheck file: cannot stat %s: %v\n", path, statErr) + os.Exit(1) + } + ts = info.ModTime() + } + + age := time.Since(ts) + if age > time.Duration(maxAgeSec)*time.Second { + fmt.Fprintf(os.Stderr, "healthcheck file: %s is %.0fs old (max %ds)\n", path, age.Seconds(), maxAgeSec) + os.Exit(1) + } +} diff --git a/backend/cmd/runner/main.go b/backend/cmd/runner/main.go new file mode 100644 index 0000000..10b228c --- /dev/null +++ b/backend/cmd/runner/main.go @@ -0,0 +1,139 @@ +// Command runner is the homelab worker binary. +// +// It polls PocketBase for pending scrape and audio tasks, executes them, and +// writes results back. It connects directly to PocketBase and MinIO using +// admin credentials loaded from environment variables. +// +// Usage: +// +// runner # start polling loop (blocks until SIGINT/SIGTERM) +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/libnovel/backend/internal/browser" + "github.com/libnovel/backend/internal/config" + "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/novelfire" + "github.com/libnovel/backend/internal/runner" + "github.com/libnovel/backend/internal/storage" +) + +// version and commit are set at build time via -ldflags. +var ( + version = "dev" + commit = "unknown" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "runner: fatal: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg := config.Load() + + // ── Logger ────────────────────────────────────────────────────────────── + log := buildLogger(cfg.LogLevel) + log.Info("runner starting", + "version", version, + "commit", commit, + "worker_id", cfg.Runner.WorkerID, + ) + + // ── Context: cancel on SIGINT / SIGTERM ───────────────────────────────── + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // ── Storage ───────────────────────────────────────────────────────────── + store, err := storage.NewStore(ctx, cfg, log) + if err != nil { + return fmt.Errorf("init storage: %w", err) + } + + // ── Browser / Scraper ─────────────────────────────────────────────────── + workers := cfg.Runner.Workers + if workers <= 0 { + workers = runtime.NumCPU() + } + timeout := cfg.Runner.Timeout + if timeout <= 0 { + timeout = 90 * time.Second + } + + browserClient := browser.NewDirectClient(browser.Config{ + MaxConcurrent: workers, + Timeout: timeout, + ProxyURL: cfg.Runner.ProxyURL, + }) + novel := novelfire.New(browserClient, log) + + // ── Kokoro ────────────────────────────────────────────────────────────── + var kokoroClient kokoro.Client + if cfg.Kokoro.URL != "" { + kokoroClient = kokoro.New(cfg.Kokoro.URL) + log.Info("kokoro TTS enabled", "url", cfg.Kokoro.URL) + } else { + log.Warn("KOKORO_URL not set — audio tasks will fail") + kokoroClient = &noopKokoro{} + } + + // ── Runner ────────────────────────────────────────────────────────────── + rCfg := runner.Config{ + WorkerID: cfg.Runner.WorkerID, + PollInterval: cfg.Runner.PollInterval, + MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape, + MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, + OrchestratorWorkers: workers, + } + deps := runner.Dependencies{ + Consumer: store, + BookWriter: store, + BookReader: store, + AudioStore: store, + Novel: novel, + Kokoro: kokoroClient, + Log: log, + } + r := runner.New(rCfg, deps) + + return r.Run(ctx) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func buildLogger(level string) *slog.Logger { + var lvl slog.Level + switch level { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) +} + +// noopKokoro is a no-op implementation used when KOKORO_URL is not set. +type noopKokoro struct{} + +func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) { + return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)") +} + +func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) { + return nil, nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c3f7f52 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,29 @@ +module github.com/libnovel/backend + +go 1.26.1 + +require ( + github.com/minio/minio-go/v7 v7.0.98 + golang.org/x/net v0.51.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..f4750f9 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,45 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +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/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/healthcheck b/backend/healthcheck new file mode 100755 index 0000000..9d0e8ec Binary files /dev/null and b/backend/healthcheck differ diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go new file mode 100644 index 0000000..e7fb619 --- /dev/null +++ b/backend/internal/backend/handlers.go @@ -0,0 +1,937 @@ +package backend + +// handlers.go — all HTTP request handlers for the backend server. +// +// Handler naming mirrors the route table in server.go: +// handleScrapeCatalogue, handleScrapeBook, handleScrapeBookRange +// handleScrapeStatus, handleScrapeTasks +// handleBrowse, handleSearch +// handleGetRanking, handleGetCover +// handleBookPreview, handleChapterText, handleReindex +// handleChapterText, handleReindex +// handleAudioGenerate, handleAudioStatus, handleAudioProxy +// handleVoices +// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample +// handlePresignAvatarUpload, handlePresignAvatar +// handleGetProgress, handleSetProgress, handleDeleteProgress +// +// Key design choices vs. old scraper: +// - POST /scrape* creates a PocketBase task record and returns 202 with the +// task_id — it does NOT run the orchestrator inline. +// - POST /api/audio creates a PocketBase audio task and returns 202 — the +// runner binary executes TTS generation asynchronously. +// - GET /api/audio/status polls PocketBase for the task record status. +// - GET /api/audio-proxy reads the completed audio object from MinIO via a +// presigned URL redirect (the runner has already uploaded the bytes). +// - GET /api/browse and /api/search fetch novelfire.net live (no MinIO cache). +// - GET /api/cover redirects to the source cover URL live. +// - GET /api/ranking reads from the PocketBase ranking collection (populated +// by the runner after each catalogue scrape). +// - GET /api/book-preview returns stored data when in library, or enqueues a +// scrape task and returns 202 when not. The backend never scrapes directly. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/kokoro" +) + +const ( + novelFireBase = "https://novelfire.net" + novelFireDomain = "novelfire.net" +) + +// ── Scrape task creation ─────────────────────────────────────────────────────── + +// handleScrapeCatalogue handles POST /scrape. +// Creates a "catalogue" scrape task in PocketBase and returns 202 with the task ID. +func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) { + taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "catalogue", "", 0, 0) + if err != nil { + s.deps.Log.Error("handleScrapeCatalogue: CreateScrapeTask failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to create task") + return + } + writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"}) +} + +// handleScrapeBook handles POST /scrape/book. +// Body: {"url": "https://novelfire.net/book/..."} +func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) { + var body struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { + jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`) + return + } + taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book", body.URL, 0, 0) + if err != nil { + s.deps.Log.Error("handleScrapeBook: CreateScrapeTask failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to create task") + return + } + writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"}) +} + +// handleScrapeBookRange handles POST /scrape/book/range. +// Body: {"url": "...", "from": N, "to": M} +func (s *Server) handleScrapeBookRange(w http.ResponseWriter, r *http.Request) { + var body struct { + URL string `json:"url"` + From int `json:"from"` + To int `json:"to"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { + jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`) + return + } + taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book_range", body.URL, body.From, body.To) + if err != nil { + s.deps.Log.Error("handleScrapeBookRange: CreateScrapeTask failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to create task") + return + } + writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"}) +} + +// handleCancelTask handles POST /api/cancel-task/{id}. +// Transitions a pending task (scrape or audio) to status=cancelled. +// Returns 404 if the task does not exist, 409 if it cannot be cancelled +// (e.g. already running/done). +func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + jsonError(w, http.StatusBadRequest, "missing task id") + return + } + if err := s.deps.Producer.CancelTask(r.Context(), id); err != nil { + s.deps.Log.Warn("handleCancelTask: CancelTask failed", "id", id, "err", err) + jsonError(w, http.StatusConflict, "could not cancel task: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled", "id": id}) +} + +// ── Scrape task status / history ─────────────────────────────────────────────── + +// handleScrapeStatus handles GET /api/scrape/status. +// Returns the most recent scrape task status (or {"running":false} if none). +func (s *Server) handleScrapeStatus(w http.ResponseWriter, r *http.Request) { + tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context()) + if err != nil { + s.deps.Log.Error("handleScrapeStatus: ListScrapeTasks failed", "err", err) + writeJSON(w, 0, map[string]bool{"running": false}) + return + } + running := false + for _, t := range tasks { + if t.Status == domain.TaskStatusRunning || t.Status == domain.TaskStatusPending { + running = true + break + } + } + writeJSON(w, 0, map[string]bool{"running": running}) +} + +// handleScrapeTasks handles GET /api/scrape/tasks. +// Returns all scrape task records from PocketBase, newest first. +func (s *Server) handleScrapeTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context()) + if err != nil { + s.deps.Log.Error("handleScrapeTasks: ListScrapeTasks failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to list tasks") + return + } + if tasks == nil { + tasks = []domain.ScrapeTask{} + } + writeJSON(w, 0, tasks) +} + +// ── Browse & search ──────────────────────────────────────────────────────────── + +// NovelListing represents a single novel entry from the novelfire browse/search page. +type NovelListing struct { + Slug string `json:"slug"` + Title string `json:"title"` + Cover string `json:"cover"` + Rank string `json:"rank"` + Rating string `json:"rating"` + Chapters string `json:"chapters"` + URL string `json:"url"` +} + +// handleBrowse handles GET /api/browse. +// Fetches novelfire.net live (no MinIO cache in the new backend). +// Query params: page (default 1), genre (default "all"), sort (default "popular"), +// status (default "all"), type (default "all-novel") +func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page := q.Get("page") + if page == "" { + page = "1" + } + genre := q.Get("genre") + if genre == "" { + genre = "all" + } + sortBy := q.Get("sort") + if sortBy == "" { + sortBy = "popular" + } + status := q.Get("status") + if status == "" { + status = "all" + } + novelType := q.Get("type") + if novelType == "" { + novelType = "all-novel" + } + + pageNum, _ := strconv.Atoi(page) + if pageNum <= 0 { + pageNum = 1 + } + + targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d", + novelFireBase, genre, sortBy, status, novelType, pageNum) + + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + + novels, hasNext, err := s.fetchBrowsePage(ctx, targetURL) + if err != nil { + s.deps.Log.Error("handleBrowse: fetch failed", "url", targetURL, "err", err) + jsonError(w, http.StatusBadGateway, err.Error()) + return + } + + w.Header().Set("Cache-Control", "public, max-age=300") + writeJSON(w, 0, map[string]any{ + "novels": novels, + "page": pageNum, + "hasNext": hasNext, + }) +} + +// handleSearch handles GET /api/search. +// Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all") +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if len([]rune(q)) < 2 { + jsonError(w, http.StatusBadRequest, "query must be at least 2 characters") + return + } + + source := r.URL.Query().Get("source") + if source == "" { + source = "all" + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + var localResults, remoteResults []NovelListing + + // Local search (PocketBase books) + if source == "local" || source == "all" { + books, err := s.deps.BookReader.ListBooks(ctx) + if err != nil { + s.deps.Log.Warn("search: ListBooks failed", "err", err) + } else { + qLower := strings.ToLower(q) + for _, b := range books { + if strings.Contains(strings.ToLower(b.Title), qLower) || + strings.Contains(strings.ToLower(b.Author), qLower) { + localResults = append(localResults, NovelListing{ + Slug: b.Slug, + Title: b.Title, + Cover: b.Cover, + URL: b.SourceURL, + }) + } + } + } + } + + // Remote search (novelfire.net) + if source == "remote" || source == "all" { + searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) + if err == nil { + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + parsed, _ := parseBrowsePage(resp.Body) + remoteResults = parsed + } + } + } + } + + // Merge: local first, de-duplicate remote + localSlugs := make(map[string]bool, len(localResults)) + for _, item := range localResults { + localSlugs[item.Slug] = true + } + combined := make([]NovelListing, 0, len(localResults)+len(remoteResults)) + combined = append(combined, localResults...) + for _, item := range remoteResults { + if !localSlugs[item.Slug] { + combined = append(combined, item) + } + } + + writeJSON(w, 0, map[string]any{ + "results": combined, + "local_count": len(localResults), + "remote_count": len(remoteResults), + }) +} + +// ── Ranking ──────────────────────────────────────────────────────────────────── + +// handleGetRanking handles GET /api/ranking. +// Returns all ranking items sorted by rank ascending. +func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) { + items, err := s.deps.RankingStore.ReadRankingItems(r.Context()) + if err != nil { + s.deps.Log.Error("handleGetRanking: ReadRankingItems failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to read ranking") + return + } + if items == nil { + items = []domain.RankingItem{} + } + writeJSON(w, 0, items) +} + +// handleGetCover handles GET /api/cover/{domain}/{slug}. +// The new backend does not cache covers in MinIO. Instead it redirects the +// client to the novelfire.net source URL. The domain path segment is kept for +// API compatibility with the old scraper. +func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + http.Error(w, "missing slug", http.StatusBadRequest) + return + } + // Redirect to the standard novelfire cover CDN URL. If the caller has the + // actual cover URL stored in metadata they should use it directly; this + // endpoint is a best-effort fallback. + coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug) + http.Redirect(w, r, coverURL, http.StatusFound) +} + +// ── Preview (live scrape, no store writes) ───────────────────────────────────── + +// handleBookPreview handles GET /api/book-preview/{slug}. +// +// If the book is already in the library (PocketBase), returns its metadata and +// chapter index immediately (200). +// +// If the book is not yet in the library, enqueues a "book" scrape task and +// returns 202 Accepted with the task_id. The runner will scrape the book +// asynchronously; the client should poll GET /api/scrape/status or +// GET /api/scrape/tasks to detect completion, then re-request this endpoint. +// +// The backend never scrapes directly — all scraping is the runner's job. +func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + + ctx := r.Context() + + meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug) + if err != nil { + s.deps.Log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err) + inLib = false + } + + if inLib { + // Fast path: book is already scraped — return stored data. + chapters, cerr := s.deps.BookReader.ListChapters(ctx, slug) + if cerr != nil { + s.deps.Log.Warn("book-preview: ListChapters failed", "slug", slug, "err", cerr) + } + writeJSON(w, 0, map[string]any{ + "in_lib": true, + "meta": meta, + "chapters": chapters, + }) + return + } + + // Book not in library — enqueue a range scrape task for the first 20 chapters + // so the user can start reading quickly. Remaining chapters can be scraped + // later via the book detail page or the admin scrape panel. + bookURL := r.URL.Query().Get("source_url") + if bookURL == "" { + bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug) + } + + const previewFrom, previewTo = 1, 20 + taskID, err := s.deps.Producer.CreateScrapeTask(ctx, "book_range", bookURL, previewFrom, previewTo) + if err != nil { + s.deps.Log.Error("book-preview: CreateScrapeTask failed", "slug", slug, "err", err) + jsonError(w, http.StatusInternalServerError, "failed to enqueue scrape task") + return + } + + s.deps.Log.Info("book-preview: enqueued range scrape task", "slug", slug, "task_id", taskID, + "from", previewFrom, "to", previewTo) + writeJSON(w, http.StatusAccepted, map[string]any{ + "in_lib": false, + "task_id": taskID, + "message": fmt.Sprintf("scraping first %d chapters; poll /api/scrape/tasks for completion", previewTo), + }) +} + +// ── Chapter text ─────────────────────────────────────────────────────────────── + +// handleChapterText handles GET /api/chapter-text/{slug}/{n}. +// Returns plain text (markdown stripped) of a stored chapter. +func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 { + http.NotFound(w, r) + return + } + raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprint(w, stripMarkdown(raw)) +} + +// handleChapterMarkdown handles GET /api/chapter-markdown/{slug}/{n}. +// +// Returns the raw markdown content of a stored chapter directly from MinIO. +// This is used by the SvelteKit UI as a simpler alternative to presign+fetch: +// it avoids the need for the SvelteKit server to reach MinIO directly, and +// gives a clean 404 when the chapter has not been scraped yet. +func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || slug == "" { + http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest) + return + } + raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n) + if err != nil { + s.deps.Log.Warn("chapter-markdown: not found in MinIO", "slug", slug, "n", n, "err", err) + http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprint(w, raw) +} + +// handleReindex handles POST /api/reindex/{slug}. +// Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects. +func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + + count, err := s.deps.BookReader.ReindexChapters(r.Context(), slug) + if err != nil { + s.deps.Log.Error("reindex failed", "slug", slug, "indexed", count, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + "indexed": count, + }) + return + } + + s.deps.Log.Info("reindex complete", "slug", slug, "indexed", count) + writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count}) +} + +// ── Audio ────────────────────────────────────────────────────────────────────── + +// handleAudioGenerate handles POST /api/audio/{slug}/{n}. +// Creates an audio_jobs task in PocketBase (runner executes asynchronously). +// Returns 200 immediately if audio already exists in MinIO. +// Returns 202 with the task_id if a new task was created. +func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 { + jsonError(w, http.StatusBadRequest, "invalid chapter") + return + } + + voice := s.cfg.DefaultVoice + var body struct { + Voice string `json:"voice"` + } + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + if body.Voice != "" { + voice = body.Voice + } + + cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice) + + // Fast path: audio already in MinIO + audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice) + if s.deps.AudioStore.AudioExists(r.Context(), audioKey) { + proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice) + writeJSON(w, 0, map[string]string{"url": proxyURL, "status": "done"}) + return + } + + // Check if a task is already pending/running + task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey) + if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) { + writeJSON(w, http.StatusAccepted, map[string]string{ + "task_id": task.ID, + "status": string(task.Status), + }) + return + } + + // Create a new audio task + taskID, err := s.deps.Producer.CreateAudioTask(r.Context(), slug, n, voice) + if err != nil { + s.deps.Log.Error("handleAudioGenerate: CreateAudioTask failed", "err", err) + jsonError(w, http.StatusInternalServerError, "failed to create audio task") + return + } + + writeJSON(w, http.StatusAccepted, map[string]string{ + "task_id": taskID, + "status": "pending", + }) +} + +// handleAudioStatus handles GET /api/audio/status/{slug}/{n}. +// Polls PocketBase for the audio task status. +// Query params: voice (optional, defaults to DefaultVoice) +func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || slug == "" { + jsonError(w, http.StatusBadRequest, "invalid params") + return + } + + voice := r.URL.Query().Get("voice") + if voice == "" { + voice = s.cfg.DefaultVoice + } + + // Fast path: audio exists in MinIO + audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice) + if s.deps.AudioStore.AudioExists(r.Context(), audioKey) { + proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice) + writeJSON(w, 0, map[string]string{ + "status": "done", + "url": proxyURL, + }) + return + } + + cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice) + task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey) + if !found { + writeJSON(w, 0, map[string]string{"status": "idle"}) + return + } + + resp := map[string]string{ + "status": string(task.Status), + "task_id": task.ID, + } + if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" { + resp["error"] = task.ErrorMessage + } + writeJSON(w, 0, resp) +} + +// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}. +// Redirects to a presigned MinIO URL for the generated audio object. +// Query params: voice (optional, defaults to DefaultVoice) +func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 { + http.NotFound(w, r) + return + } + + voice := r.URL.Query().Get("voice") + if voice == "" { + voice = s.cfg.DefaultVoice + } + + audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice) + if !s.deps.AudioStore.AudioExists(r.Context(), audioKey) { + http.Error(w, "audio not generated yet", http.StatusNotFound) + return + } + + presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour) + if err != nil { + s.deps.Log.Error("handleAudioProxy: PresignAudio failed", "slug", slug, "n", n, "err", err) + http.Error(w, "presign failed", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, presignURL, http.StatusFound) +} + +// ── Voices ───────────────────────────────────────────────────────────────────── + +// handleVoices handles GET /api/voices. +// Returns {"voices": [...]} — fetched from Kokoro with built-in fallback. +func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())}) +} + +// ── Presigned URLs ───────────────────────────────────────────────────────────── + +// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}. +func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || slug == "" { + jsonError(w, http.StatusBadRequest, "invalid params") + return + } + + u, err := s.deps.PresignStore.PresignChapter(r.Context(), slug, n, 15*time.Minute) + if err != nil { + s.deps.Log.Error("presign chapter failed", "slug", slug, "n", n, "err", err) + jsonError(w, http.StatusInternalServerError, "presign failed") + return + } + writeJSON(w, 0, map[string]string{"url": u}) +} + +// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}. +// Query params: voice (optional) +func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 || slug == "" { + jsonError(w, http.StatusBadRequest, "invalid params") + return + } + + voice := r.URL.Query().Get("voice") + if voice == "" { + voice = s.cfg.DefaultVoice + } + + key := s.deps.AudioStore.AudioObjectKey(slug, n, voice) + if !s.deps.AudioStore.AudioExists(r.Context(), key) { + http.NotFound(w, r) + return + } + + u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour) + if err != nil { + s.deps.Log.Error("presign audio failed", "slug", slug, "n", n, "err", err) + jsonError(w, http.StatusInternalServerError, "presign failed") + return + } + writeJSON(w, 0, map[string]string{"url": u}) +} + +// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}. +func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) { + voice := r.PathValue("voice") + if voice == "" { + jsonError(w, http.StatusBadRequest, "missing voice") + return + } + + key := kokoro.VoiceSampleKey(voice) + if !s.deps.AudioStore.AudioExists(r.Context(), key) { + http.NotFound(w, r) + return + } + + u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour) + if err != nil { + s.deps.Log.Error("presign voice sample failed", "voice", voice, "err", err) + jsonError(w, http.StatusInternalServerError, "presign failed") + return + } + writeJSON(w, 0, map[string]string{"url": u}) +} + +// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}. +// Query params: ext (jpg|png|webp, defaults to jpg) +func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("userId") + if userID == "" { + jsonError(w, http.StatusBadRequest, "missing userId") + return + } + + ext := r.URL.Query().Get("ext") + switch ext { + case "jpg", "jpeg": + ext = "jpg" + case "png": + ext = "png" + case "webp": + ext = "webp" + default: + ext = "jpg" + } + + uploadURL, key, err := s.deps.PresignStore.PresignAvatarUpload(r.Context(), userID, ext) + if err != nil { + s.deps.Log.Error("presign avatar upload failed", "userId", userID, "err", err) + jsonError(w, http.StatusInternalServerError, "presign failed") + return + } + writeJSON(w, 0, map[string]string{"upload_url": uploadURL, "key": key}) +} + +// handlePresignAvatar handles GET /api/presign/avatar/{userId}. +func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("userId") + if userID == "" { + jsonError(w, http.StatusBadRequest, "missing userId") + return + } + + u, found, err := s.deps.PresignStore.PresignAvatarURL(r.Context(), userID) + if err != nil { + s.deps.Log.Error("presign avatar failed", "userId", userID, "err", err) + jsonError(w, http.StatusInternalServerError, "presign failed") + return + } + if !found { + http.NotFound(w, r) + return + } + writeJSON(w, 0, map[string]string{"url": u}) +} + +// ── Progress ─────────────────────────────────────────────────────────────────── + +// handleGetProgress handles GET /api/progress. +// Returns {"slug": chapterNum, "slug_ts": timestampMs, ...} +func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) { + sid := ensureSession(w, r) + entries, err := s.deps.ProgressStore.AllProgress(r.Context(), sid) + if err != nil { + s.deps.Log.Error("AllProgress failed", "err", err) + entries = nil + } + + progress := make(map[string]any, len(entries)*2) + for _, p := range entries { + progress[p.Slug] = p.Chapter + progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli() + } + writeJSON(w, 0, progress) +} + +// handleSetProgress handles POST /api/progress/{slug}. +// Body: {"chapter": N} +func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) { + sid := ensureSession(w, r) + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + + var body struct { + Chapter int `json:"chapter"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 { + jsonError(w, http.StatusBadRequest, "invalid body") + return + } + + p := domain.ReadingProgress{ + Slug: slug, + Chapter: body.Chapter, + UpdatedAt: time.Now(), + } + if err := s.deps.ProgressStore.SetProgress(r.Context(), sid, p); err != nil { + s.deps.Log.Error("SetProgress failed", "slug", slug, "err", err) + jsonError(w, http.StatusInternalServerError, "store error") + return + } + writeJSON(w, 0, map[string]string{}) +} + +// handleDeleteProgress handles DELETE /api/progress/{slug}. +func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) { + sid := ensureSession(w, r) + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + + if err := s.deps.ProgressStore.DeleteProgress(r.Context(), sid, slug); err != nil { + s.deps.Log.Error("DeleteProgress failed", "slug", slug, "err", err) + // non-fatal + } + writeJSON(w, 0, map[string]string{}) +} + +// ── Browse page parsing helpers ──────────────────────────────────────────────── + +// fetchBrowsePage fetches pageURL and parses NovelListings from the HTML. +func (s *Server) fetchBrowsePage(ctx context.Context, pageURL string) ([]NovelListing, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) + if err != nil { + return nil, false, fmt.Errorf("build request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, false, fmt.Errorf("fetch %s: %w", pageURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, false, fmt.Errorf("upstream returned %d", resp.StatusCode) + } + + novels, hasNext := parseBrowsePage(resp.Body) + return novels, hasNext, nil +} + +// parseBrowsePage parses a novelfire HTML body and returns novel listings. +// It uses a simple string-scanning approach to avoid importing golang.org/x/net/html +// in this package (that dependency is only in internal/novelfire). +func parseBrowsePage(r io.Reader) ([]NovelListing, bool) { + data, err := io.ReadAll(r) + if err != nil { + return nil, false + } + body := string(data) + + var novels []NovelListing + hasNext := false + + // Detect "next page" link + if strings.Contains(body, `rel="next"`) || + strings.Contains(body, `aria-label="Next"`) || + strings.Contains(body, `class="next"`) { + hasNext = true + } + + // Extract novel slugs and titles using simple regex patterns. + // novelfire.net novel items:
  • ...
  • + // Each contains an anchor like + slugRe := regexp.MustCompile(`href="/book/([^/"]+)"`) + titleRe := regexp.MustCompile(`class="novel-title[^"]*"[^>]*>([^<]+)<`) + coverRe := regexp.MustCompile(`data-src="(https?://[^"]+)"`) + + slugMatches := slugRe.FindAllStringSubmatch(body, -1) + titleMatches := titleRe.FindAllStringSubmatch(body, -1) + coverMatches := coverRe.FindAllStringSubmatch(body, -1) + + seen := make(map[string]bool) + for i, sm := range slugMatches { + slug := sm[1] + if seen[slug] { + continue + } + seen[slug] = true + + novel := NovelListing{ + Slug: slug, + URL: novelFireBase + "/book/" + slug, + } + if i < len(titleMatches) { + novel.Title = strings.TrimSpace(titleMatches[i][1]) + } + if i < len(coverMatches) { + novel.Cover = coverMatches[i][1] + } + if novel.Title != "" { + novels = append(novels, novel) + } + } + + return novels, hasNext +} + +// ── Markdown stripping ───────────────────────────────────────────────────────── + +// stripMarkdown removes common markdown syntax from src, returning plain text. +func stripMarkdown(src string) string { + src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "") + src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "") + src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "") + src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "") + src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1") + src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "") + src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "") + src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "") + src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n") + return strings.TrimSpace(src) +} + +// ── Hardcoded Kokoro voice fallback ─────────────────────────────────────────── + +// kokoroVoices is the built-in fallback list used when the Kokoro service is +// unavailable. Matches the list in the old scraper helpers.go. +var kokoroVoices = []string{ + // American English + "af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", + "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", + "af_sarah", "af_sky", + "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", + "am_michael", "am_onyx", "am_puck", + // British English + "bf_alice", "bf_emma", "bf_lily", + "bm_daniel", "bm_fable", "bm_george", "bm_lewis", + // Spanish + "ef_dora", "em_alex", + // French + "ff_siwis", + // Hindi + "hf_alpha", "hf_beta", "hm_omega", "hm_psi", + // Italian + "if_sara", "im_nicola", + // Japanese + "jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo", + // Portuguese + "pf_dora", "pm_alex", + // Chinese + "zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi", + "zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang", +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go new file mode 100644 index 0000000..539aea7 --- /dev/null +++ b/backend/internal/backend/server.go @@ -0,0 +1,285 @@ +// Package backend implements the HTTP API server for the LibNovel backend. +// +// The server exposes all endpoints consumed by the SvelteKit UI: +// - Book/chapter reads from PocketBase/MinIO via bookstore interfaces +// - Task creation (scrape + audio) via taskqueue.Producer — the runner binary +// picks up and executes those tasks asynchronously +// - Presigned MinIO URLs for media playback/upload +// - Session-scoped reading progress +// - Live novelfire.net browse/search (no scraper interface needed; direct HTTP) +// - Kokoro voice list +// +// The backend never scrapes directly. All scraping (metadata, chapter list, +// chapter text, audio TTS) is delegated to the runner binary via PocketBase +// task records. GET /api/book-preview enqueues a task when the book is absent. +// +// All external dependencies are injected as interfaces; concrete types live in +// internal/storage and are wired by cmd/backend/main.go. +package backend + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/libnovel/backend/internal/bookstore" + "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/taskqueue" +) + +// Dependencies holds all external services the backend server depends on. +// Every field is an interface so test doubles can be injected freely. +type Dependencies struct { + // BookReader reads book metadata and chapter text from PocketBase/MinIO. + BookReader bookstore.BookReader + // RankingStore reads ranking data from PocketBase. + RankingStore bookstore.RankingStore + // AudioStore checks audio object existence and computes MinIO keys. + AudioStore bookstore.AudioStore + // PresignStore generates short-lived MinIO URLs. + PresignStore bookstore.PresignStore + // ProgressStore reads/writes per-session reading progress. + ProgressStore bookstore.ProgressStore + // Producer creates scrape/audio tasks in PocketBase. + Producer taskqueue.Producer + // TaskReader reads scrape/audio task records from PocketBase. + TaskReader taskqueue.Reader + // Kokoro is the TTS client (used for voice list only in the backend; + // audio generation is done by the runner). + Kokoro kokoro.Client + // Log is the structured logger. + Log *slog.Logger +} + +// Config holds HTTP server tuning parameters. +type Config struct { + // Addr is the listen address, e.g. ":8080". + Addr string + // DefaultVoice is used when no voice is specified in audio requests. + DefaultVoice string + // Version and Commit are embedded in /health and /api/version responses. + Version string + Commit string +} + +// Server is the HTTP API server. +type Server struct { + cfg Config + deps Dependencies + + // voiceMu guards cachedVoices. Populated lazily on first GET /api/voices. + voiceMu sync.RWMutex + cachedVoices []string +} + +// New creates a Server from cfg and deps. +func New(cfg Config, deps Dependencies) *Server { + if cfg.DefaultVoice == "" { + cfg.DefaultVoice = "af_bella" + } + if deps.Log == nil { + deps.Log = slog.Default() + } + return &Server{cfg: cfg, deps: deps} +} + +// ListenAndServe registers all routes and starts the HTTP server. +// It blocks until ctx is cancelled, then performs a graceful shutdown. +func (s *Server) ListenAndServe(ctx context.Context) error { + mux := http.NewServeMux() + + // Health / version + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("GET /api/version", s.handleVersion) + + // Scrape task creation (202 Accepted — runner executes asynchronously) + mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue) + mux.HandleFunc("POST /scrape/book", s.handleScrapeBook) + mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange) + + // Scrape task status / history + mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus) + mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks) + + // Cancel a pending task (scrape or audio) + mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask) + + // Browse & search (live novelfire.net) + mux.HandleFunc("GET /api/browse", s.handleBrowse) + mux.HandleFunc("GET /api/search", s.handleSearch) + + // Ranking (from PocketBase) + mux.HandleFunc("GET /api/ranking", s.handleGetRanking) + + // Cover proxy (live URL redirect) + mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover) + + // Book preview (enqueues scrape task if not in library; returns stored data if already scraped) + mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview) + + // Chapter text (served from MinIO via PocketBase index) + mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText) + // Raw markdown chapter content — served directly from MinIO by the backend. + // Use this instead of presign+fetch to avoid SvelteKit→MinIO network path. + mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown) + + // Reindex chapters_idx from MinIO + mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex) + + // Audio task creation (backend creates task; runner executes) + mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate) + mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus) + mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy) + + // Voices list + mux.HandleFunc("GET /api/voices", s.handleVoices) + + // Presigned URLs + mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter) + mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio) + mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample) + mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload) + mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar) + + // Reading progress + mux.HandleFunc("GET /api/progress", s.handleGetProgress) + mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress) + mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress) + + srv := &http.Server{ + Addr: s.cfg.Addr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + s.deps.Log.Info("backend: HTTP server listening", "addr", s.cfg.Addr) + + select { + case <-ctx.Done(): + s.deps.Log.Info("backend: context cancelled, starting graceful shutdown") + shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(shutCtx); err != nil { + s.deps.Log.Error("backend: graceful shutdown failed", "err", err) + return err + } + s.deps.Log.Info("backend: shutdown complete") + return nil + case err := <-errCh: + return err + } +} + +// ── Session cookie helpers ───────────────────────────────────────────────────── + +const sessionCookieName = "libnovel_session" + +func sessionID(r *http.Request) string { + c, err := r.Cookie(sessionCookieName) + if err != nil { + return "" + } + return c.Value +} + +func newSessionID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func ensureSession(w http.ResponseWriter, r *http.Request) string { + if id := sessionID(r); id != "" { + return id + } + id, err := newSessionID() + if err != nil { + id = fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: id, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 365 * 24 * 60 * 60, + }) + return id +} + +// ── Utility helpers ──────────────────────────────────────────────────────────── + +// writeJSON writes v as a JSON response with status code. Status 0 → 200. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + if status != 0 { + w.WriteHeader(status) + } + _ = json.NewEncoder(w).Encode(v) +} + +// jsonError writes a JSON error body and the given status code. +func jsonError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// voices returns the list of available Kokoro voices. On the first call it +// fetches from the Kokoro service and caches the result. Falls back to the +// hardcoded list on error. +func (s *Server) voices(ctx context.Context) []string { + s.voiceMu.RLock() + cached := s.cachedVoices + s.voiceMu.RUnlock() + if len(cached) > 0 { + return cached + } + + if s.deps.Kokoro == nil { + return kokoroVoices + } + + fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + list, err := s.deps.Kokoro.ListVoices(fetchCtx) + if err != nil || len(list) == 0 { + s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err) + return kokoroVoices + } + + s.voiceMu.Lock() + s.cachedVoices = list + s.voiceMu.Unlock() + s.deps.Log.Info("backend: fetched kokoro voices", "count", len(list)) + return list +} + +// handleHealth handles GET /health. +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 0, map[string]string{ + "status": "ok", + "version": s.cfg.Version, + "commit": s.cfg.Commit, + }) +} + +// handleVersion handles GET /api/version. +func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 0, map[string]string{ + "version": s.cfg.Version, + "commit": s.cfg.Commit, + }) +} diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go new file mode 100644 index 0000000..3ec281c --- /dev/null +++ b/backend/internal/bookstore/bookstore.go @@ -0,0 +1,125 @@ +// Package bookstore defines the segregated read/write interfaces for book, +// chapter, ranking, progress, audio, and presign data. +// +// Interface segregation: +// - BookWriter — used by the runner to persist scraped data. +// - BookReader — used by the backend to serve book/chapter data. +// - RankingStore — used by both runner (write) and backend (read). +// - PresignStore — used only by the backend for URL signing. +// - AudioStore — used by the runner to store audio; backend for presign. +// - ProgressStore— used only by the backend for reading progress. +// +// Concrete implementations live in internal/storage. +package bookstore + +import ( + "context" + "time" + + "github.com/libnovel/backend/internal/domain" +) + +// BookWriter is the write side used by the runner after scraping a book. +type BookWriter interface { + // WriteMetadata upserts all bibliographic fields for a book. + WriteMetadata(ctx context.Context, meta domain.BookMeta) error + + // WriteChapter stores a fully-scraped chapter's text in MinIO and + // updates the chapters_idx record in PocketBase. + WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error + + // WriteChapterRefs persists chapter metadata (number + title) into + // chapters_idx without fetching or storing chapter text. + WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error + + // ChapterExists returns true if the markdown object for ref already exists. + ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool +} + +// BookReader is the read side used by the backend to serve content. +type BookReader interface { + // ReadMetadata returns the metadata for slug. + // Returns (zero, false, nil) when not found. + ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error) + + // ListBooks returns all books sorted alphabetically by title. + ListBooks(ctx context.Context) ([]domain.BookMeta, error) + + // LocalSlugs returns the set of slugs that have metadata stored. + LocalSlugs(ctx context.Context) (map[string]bool, error) + + // MetadataMtime returns the Unix-second mtime of the metadata record, or 0. + MetadataMtime(ctx context.Context, slug string) int64 + + // ReadChapter returns the raw markdown for chapter number n. + ReadChapter(ctx context.Context, slug string, n int) (string, error) + + // ListChapters returns all stored chapters for slug, sorted by number. + ListChapters(ctx context.Context, slug string) ([]domain.ChapterInfo, error) + + // CountChapters returns the count of stored chapters. + CountChapters(ctx context.Context, slug string) int + + // ReindexChapters rebuilds chapters_idx from MinIO objects for slug. + ReindexChapters(ctx context.Context, slug string) (int, error) +} + +// RankingStore covers ranking reads and writes. +type RankingStore interface { + // WriteRankingItem upserts a single ranking entry (keyed on Slug). + WriteRankingItem(ctx context.Context, item domain.RankingItem) error + + // ReadRankingItems returns all ranking items sorted by rank ascending. + ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error) + + // RankingFreshEnough returns true when ranking rows exist and the most + // recent Updated timestamp is within maxAge. + RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) +} + +// AudioStore covers audio object storage (runner writes; backend reads). +type AudioStore interface { + // AudioObjectKey returns the MinIO object key for a cached audio file. + AudioObjectKey(slug string, n int, voice string) string + + // AudioExists returns true when the audio object is present in MinIO. + AudioExists(ctx context.Context, key string) bool + + // PutAudio stores raw audio bytes under the given MinIO object key. + PutAudio(ctx context.Context, key string, data []byte) error +} + +// PresignStore generates short-lived URLs — used exclusively by the backend. +type PresignStore interface { + // PresignChapter returns a presigned GET URL for a chapter markdown object. + PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) + + // PresignAudio returns a presigned GET URL for an audio object. + PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) + + // PresignAvatarUpload returns a short-lived presigned PUT URL for uploading + // an avatar image. ext should be "jpg", "png", or "webp". + PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) + + // PresignAvatarURL returns a presigned GET URL for a user's avatar. + // Returns ("", false, nil) when no avatar exists. + PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) + + // DeleteAvatar removes all avatar objects for a user. + DeleteAvatar(ctx context.Context, userID string) error +} + +// ProgressStore covers per-session reading progress — backend only. +type ProgressStore interface { + // GetProgress returns the reading progress for the given session + slug. + GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool) + + // SetProgress saves or updates reading progress. + SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error + + // AllProgress returns all progress entries for a session. + AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error) + + // DeleteProgress removes progress for a specific slug. + DeleteProgress(ctx context.Context, sessionID, slug string) error +} diff --git a/backend/internal/bookstore/bookstore_test.go b/backend/internal/bookstore/bookstore_test.go new file mode 100644 index 0000000..2bbc5d0 --- /dev/null +++ b/backend/internal/bookstore/bookstore_test.go @@ -0,0 +1,138 @@ +package bookstore_test + +import ( + "context" + "testing" + "time" + + "github.com/libnovel/backend/internal/bookstore" + "github.com/libnovel/backend/internal/domain" +) + +// ── Mock that satisfies all bookstore interfaces ────────────────────────────── + +type mockStore struct{} + +// BookWriter +func (m *mockStore) WriteMetadata(_ context.Context, _ domain.BookMeta) error { return nil } +func (m *mockStore) WriteChapter(_ context.Context, _ string, _ domain.Chapter) error { return nil } +func (m *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error { + return nil +} +func (m *mockStore) ChapterExists(_ context.Context, _ string, _ domain.ChapterRef) bool { + return false +} + +// BookReader +func (m *mockStore) ReadMetadata(_ context.Context, _ string) (domain.BookMeta, bool, error) { + return domain.BookMeta{}, false, nil +} +func (m *mockStore) ListBooks(_ context.Context) ([]domain.BookMeta, error) { return nil, nil } +func (m *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) { + return map[string]bool{}, nil +} +func (m *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 } +func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) { + return "", nil +} +func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) { + return nil, nil +} +func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 } +func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil } + +// RankingStore +func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil } +func (m *mockStore) ReadRankingItems(_ context.Context) ([]domain.RankingItem, error) { + return nil, nil +} +func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) { + return false, nil +} + +// AudioStore +func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" } +func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false } +func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil } + +// PresignStore +func (m *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) { + return "", nil +} +func (m *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) { + return "", nil +} +func (m *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) { + return "", "", nil +} +func (m *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) { + return "", false, nil +} +func (m *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil } + +// ProgressStore +func (m *mockStore) GetProgress(_ context.Context, _, _ string) (domain.ReadingProgress, bool) { + return domain.ReadingProgress{}, false +} +func (m *mockStore) SetProgress(_ context.Context, _ string, _ domain.ReadingProgress) error { + return nil +} +func (m *mockStore) AllProgress(_ context.Context, _ string) ([]domain.ReadingProgress, error) { + return nil, nil +} +func (m *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil } + +// ── Compile-time interface satisfaction ─────────────────────────────────────── + +var _ bookstore.BookWriter = (*mockStore)(nil) +var _ bookstore.BookReader = (*mockStore)(nil) +var _ bookstore.RankingStore = (*mockStore)(nil) +var _ bookstore.AudioStore = (*mockStore)(nil) +var _ bookstore.PresignStore = (*mockStore)(nil) +var _ bookstore.ProgressStore = (*mockStore)(nil) + +// ── Behavioural tests ───────────────────────────────────────────────────────── + +func TestBookWriter_WriteMetadata_ReturnsNilError(t *testing.T) { + var w bookstore.BookWriter = &mockStore{} + if err := w.WriteMetadata(context.Background(), domain.BookMeta{Slug: "test"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestBookReader_ReadMetadata_NotFound(t *testing.T) { + var r bookstore.BookReader = &mockStore{} + _, found, err := r.ReadMetadata(context.Background(), "unknown") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if found { + t.Error("expected not found") + } +} + +func TestRankingStore_RankingFreshEnough_ReturnsFalse(t *testing.T) { + var s bookstore.RankingStore = &mockStore{} + fresh, err := s.RankingFreshEnough(context.Background(), time.Hour) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fresh { + t.Error("expected false") + } +} + +func TestAudioStore_AudioExists_ReturnsFalse(t *testing.T) { + var s bookstore.AudioStore = &mockStore{} + if s.AudioExists(context.Background(), "audio/slug/1/af_bella.mp3") { + t.Error("expected false") + } +} + +func TestProgressStore_GetProgress_NotFound(t *testing.T) { + var s bookstore.ProgressStore = &mockStore{} + _, found := s.GetProgress(context.Background(), "session-1", "slug") + if found { + t.Error("expected not found") + } +} diff --git a/backend/internal/browser/browser.go b/backend/internal/browser/browser.go new file mode 100644 index 0000000..4de0147 --- /dev/null +++ b/backend/internal/browser/browser.go @@ -0,0 +1,206 @@ +// Package browser provides a rate-limited HTTP client for web scraping. +// The Client interface is the only thing the rest of the codebase depends on; +// the concrete DirectClient can be swapped for any other implementation +// (e.g. a Browserless-backed client) without touching callers. +package browser + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "sync" + "time" +) + +// ErrRateLimit is returned by GetContent when the server responds with 429. +// It carries the suggested retry delay (from Retry-After header, or a default). +var ErrRateLimit = errors.New("rate limited (429)") + +// RateLimitError wraps ErrRateLimit and carries the suggested wait duration. +type RateLimitError struct { + // RetryAfter is how long the caller should wait before retrying. + // Derived from the Retry-After response header when present; otherwise a default. + RetryAfter time.Duration +} + +func (e *RateLimitError) Error() string { + return fmt.Sprintf("rate limited (429): retry after %s", e.RetryAfter) +} + +func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimit } + +// defaultRateLimitDelay is used when the server returns 429 with no Retry-After header. +const defaultRateLimitDelay = 60 * time.Second + +// Client is the interface used by scrapers to fetch raw page HTML. +// Implementations must be safe for concurrent use. +type Client interface { + // GetContent fetches the URL and returns the full response body as a string. + // It should respect the provided context for cancellation and timeouts. + GetContent(ctx context.Context, pageURL string) (string, error) +} + +// Config holds tunable parameters for the direct HTTP client. +type Config struct { + // MaxConcurrent limits the number of simultaneous in-flight requests. + // Defaults to 5 when 0. + MaxConcurrent int + // Timeout is the per-request deadline. Defaults to 90s when 0. + Timeout time.Duration + // ProxyURL is an optional outbound proxy, e.g. "http://user:pass@host:3128". + // Falls back to HTTP_PROXY / HTTPS_PROXY environment variables when empty. + ProxyURL string +} + +// DirectClient is a plain net/http-based Client with a concurrency semaphore. +type DirectClient struct { + http *http.Client + semaphore chan struct{} +} + +// NewDirectClient returns a DirectClient configured by cfg. +func NewDirectClient(cfg Config) *DirectClient { + if cfg.MaxConcurrent <= 0 { + cfg.MaxConcurrent = 5 + } + if cfg.Timeout <= 0 { + cfg.Timeout = 90 * time.Second + } + + transport := &http.Transport{ + MaxIdleConnsPerHost: cfg.MaxConcurrent * 2, + DisableCompression: false, + } + if cfg.ProxyURL != "" { + proxyParsed, err := url.Parse(cfg.ProxyURL) + if err == nil { + transport.Proxy = http.ProxyURL(proxyParsed) + } + } else { + transport.Proxy = http.ProxyFromEnvironment + } + + return &DirectClient{ + http: &http.Client{ + Transport: transport, + Timeout: cfg.Timeout, + }, + semaphore: make(chan struct{}, cfg.MaxConcurrent), + } +} + +// GetContent fetches pageURL respecting the concurrency limit. +func (c *DirectClient) GetContent(ctx context.Context, pageURL string) (string, error) { + // Acquire semaphore slot. + select { + case c.semaphore <- struct{}{}: + case <-ctx.Done(): + return "", ctx.Err() + } + defer func() { <-c.semaphore }() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) + if err != nil { + return "", fmt.Errorf("browser: build request %s: %w", pageURL, err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.5") + + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("browser: GET %s: %w", pageURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + delay := defaultRateLimitDelay + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs > 0 { + delay = time.Duration(secs) * time.Second + } + } + return "", &RateLimitError{RetryAfter: delay} + } + + if resp.StatusCode >= 400 { + return "", fmt.Errorf("browser: GET %s returned %d", pageURL, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("browser: read body %s: %w", pageURL, err) + } + return string(body), nil +} + +// Do implements httputil.Client so DirectClient can be passed to RetryGet. +func (c *DirectClient) Do(req *http.Request) (*http.Response, error) { + select { + case c.semaphore <- struct{}{}: + case <-req.Context().Done(): + return nil, req.Context().Err() + } + defer func() { <-c.semaphore }() + return c.http.Do(req) +} + +// ── Stub for testing ────────────────────────────────────────────────────────── + +// StubClient is a test double for Client. It returns pre-configured responses +// keyed on URL. Calls to unknown URLs return an error. +type StubClient struct { + mu sync.Mutex + pages map[string]string + errors map[string]error + callLog []string +} + +// NewStub creates a StubClient with no pages pre-loaded. +func NewStub() *StubClient { + return &StubClient{ + pages: make(map[string]string), + errors: make(map[string]error), + } +} + +// SetPage registers a URL → HTML body mapping. +func (s *StubClient) SetPage(u, html string) { + s.mu.Lock() + s.pages[u] = html + s.mu.Unlock() +} + +// SetError registers a URL → error mapping (returned instead of a body). +func (s *StubClient) SetError(u string, err error) { + s.mu.Lock() + s.errors[u] = err + s.mu.Unlock() +} + +// CallLog returns the ordered list of URLs that were requested. +func (s *StubClient) CallLog() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.callLog)) + copy(out, s.callLog) + return out +} + +// GetContent returns the registered page or an error for the URL. +func (s *StubClient) GetContent(_ context.Context, pageURL string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.callLog = append(s.callLog, pageURL) + if err, ok := s.errors[pageURL]; ok { + return "", err + } + if html, ok := s.pages[pageURL]; ok { + return html, nil + } + return "", fmt.Errorf("stub: no page registered for %q", pageURL) +} diff --git a/backend/internal/browser/browser_test.go b/backend/internal/browser/browser_test.go new file mode 100644 index 0000000..c5dd55d --- /dev/null +++ b/backend/internal/browser/browser_test.go @@ -0,0 +1,141 @@ +package browser_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/libnovel/backend/internal/browser" +) + +func TestDirectClient_GetContent_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello")) + })) + defer srv.Close() + + c := browser.NewDirectClient(browser.Config{MaxConcurrent: 2, Timeout: 5 * time.Second}) + body, err := c.GetContent(context.Background(), srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body != "hello" { + t.Errorf("want hello, got %q", body) + } +} + +func TestDirectClient_GetContent_4xxReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := browser.NewDirectClient(browser.Config{}) + _, err := c.GetContent(context.Background(), srv.URL) + if err == nil { + t.Fatal("expected error for 404") + } +} + +func TestDirectClient_SemaphoreBlocksConcurrency(t *testing.T) { + const maxConcurrent = 2 + var inflight atomic.Int32 + var peak atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := inflight.Add(1) + if int(n) > int(peak.Load()) { + peak.Store(n) + } + time.Sleep(20 * time.Millisecond) + inflight.Add(-1) + w.Write([]byte("ok")) + })) + defer srv.Close() + + c := browser.NewDirectClient(browser.Config{MaxConcurrent: maxConcurrent, Timeout: 5 * time.Second}) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c.GetContent(context.Background(), srv.URL) + }() + } + wg.Wait() + + if int(peak.Load()) > maxConcurrent { + t.Errorf("concurrent requests exceeded limit: peak=%d, limit=%d", peak.Load(), maxConcurrent) + } +} + +func TestDirectClient_ContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Write([]byte("ok")) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before making the request + + c := browser.NewDirectClient(browser.Config{}) + _, err := c.GetContent(ctx, srv.URL) + if err == nil { + t.Fatal("expected context cancellation error") + } +} + +// ── StubClient ──────────────────────────────────────────────────────────────── + +func TestStubClient_ReturnsRegisteredPage(t *testing.T) { + stub := browser.NewStub() + stub.SetPage("http://example.com/page1", "page1") + + body, err := stub.GetContent(context.Background(), "http://example.com/page1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body != "page1" { + t.Errorf("want page1 html, got %q", body) + } +} + +func TestStubClient_ReturnsRegisteredError(t *testing.T) { + stub := browser.NewStub() + want := errors.New("network failure") + stub.SetError("http://example.com/bad", want) + + _, err := stub.GetContent(context.Background(), "http://example.com/bad") + if err == nil { + t.Fatal("expected error") + } +} + +func TestStubClient_UnknownURLReturnsError(t *testing.T) { + stub := browser.NewStub() + _, err := stub.GetContent(context.Background(), "http://unknown.example.com/") + if err == nil { + t.Fatal("expected error for unknown URL") + } +} + +func TestStubClient_CallLog(t *testing.T) { + stub := browser.NewStub() + stub.SetPage("http://example.com/a", "a") + stub.SetPage("http://example.com/b", "b") + + stub.GetContent(context.Background(), "http://example.com/a") + stub.GetContent(context.Background(), "http://example.com/b") + + log := stub.CallLog() + if len(log) != 2 || log[0] != "http://example.com/a" || log[1] != "http://example.com/b" { + t.Errorf("unexpected call log: %v", log) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..0d6bf7a --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,183 @@ +// Package config loads all service configuration from environment variables. +// Both the runner and backend binaries call config.Load() at startup; each +// uses only the sub-struct relevant to it. +// +// Every field has a documented default so the service starts sensibly without +// any environment configuration (useful for local development). +package config + +import ( + "os" + "strconv" + "strings" + "time" +) + +// PocketBase holds connection settings for the remote PocketBase instance. +type PocketBase struct { + // URL is the base URL of the PocketBase instance, e.g. https://pb.libnovel.cc + URL string + // AdminEmail is the admin account email used for API authentication. + AdminEmail string + // AdminPassword is the admin account password. + AdminPassword string +} + +// MinIO holds connection settings for the remote MinIO / S3-compatible store. +type MinIO struct { + // Endpoint is the host:port of the MinIO S3 API, e.g. storage.libnovel.cc:443 + Endpoint string + // PublicEndpoint is the browser-visible endpoint used for presigned URLs. + // Falls back to Endpoint when empty. + PublicEndpoint string + // AccessKey is the MinIO access key. + AccessKey string + // SecretKey is the MinIO secret key. + SecretKey string + // UseSSL enables TLS for the internal MinIO connection. + UseSSL bool + // PublicUseSSL enables TLS for presigned URL generation. + PublicUseSSL bool + // BucketChapters is the bucket that holds chapter markdown objects. + BucketChapters string + // BucketAudio is the bucket that holds generated audio MP3 objects. + BucketAudio string + // BucketAvatars is the bucket that holds user avatar images. + BucketAvatars string +} + +// Kokoro holds connection settings for the Kokoro-FastAPI TTS service. +type Kokoro struct { + // URL is the base URL of the Kokoro service, e.g. https://kokoro.libnovel.cc + // An empty string disables TTS generation. + URL string + // DefaultVoice is the voice used when none is specified. + DefaultVoice string +} + +// HTTP holds settings for the HTTP server (backend only). +type HTTP struct { + // Addr is the listen address, e.g. ":8080" + Addr string +} + +// Runner holds settings specific to the runner/worker binary. +type Runner struct { + // PollInterval is how often the runner checks PocketBase for pending tasks. + PollInterval time.Duration + // MaxConcurrentScrape limits simultaneous book-scrape goroutines. + MaxConcurrentScrape int + // MaxConcurrentAudio limits simultaneous audio-generation goroutines. + MaxConcurrentAudio int + // WorkerID is a unique identifier for this runner instance. + // Defaults to the system hostname. + WorkerID string + // Workers is the number of chapter-scraping goroutines per book. + Workers int + // Timeout is the per-request HTTP timeout for scraping. + Timeout time.Duration + // ProxyURL is an optional outbound proxy for scraper HTTP requests. + ProxyURL string +} + +// Config is the top-level configuration struct consumed by both binaries. +type Config struct { + PocketBase PocketBase + MinIO MinIO + Kokoro Kokoro + HTTP HTTP + Runner Runner + // LogLevel is one of "debug", "info", "warn", "error". + LogLevel string +} + +// Load reads all configuration from environment variables and returns a +// populated Config. Missing variables fall back to documented defaults. +func Load() Config { + workerID, _ := os.Hostname() + if workerID == "" { + workerID = "runner-default" + } + + return Config{ + LogLevel: envOr("LOG_LEVEL", "info"), + + PocketBase: PocketBase{ + URL: envOr("POCKETBASE_URL", "http://localhost:8090"), + AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"), + AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"), + }, + + MinIO: MinIO{ + Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), + PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""), + AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), + SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), + UseSSL: envBool("MINIO_USE_SSL", false), + PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true), + BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), + BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), + BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"), + }, + + Kokoro: Kokoro{ + URL: envOr("KOKORO_URL", ""), + DefaultVoice: envOr("KOKORO_VOICE", "af_bella"), + }, + + HTTP: HTTP{ + Addr: envOr("BACKEND_HTTP_ADDR", ":8080"), + }, + + Runner: Runner{ + PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second), + MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1), + MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1), + WorkerID: envOr("RUNNER_WORKER_ID", workerID), + Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU() + Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second), + ProxyURL: envOr("SCRAPER_PROXY", ""), + }, + } +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + return strings.ToLower(v) == "true" +} + +func envInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return fallback + } + return n +} + +func envDuration(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil { + return fallback + } + return d +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..7f09925 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,127 @@ +package config_test + +import ( + "os" + "testing" + "time" + + "github.com/libnovel/backend/internal/config" +) + +func TestLoad_Defaults(t *testing.T) { + // Unset all relevant vars so we test pure defaults. + unset := []string{ + "LOG_LEVEL", + "POCKETBASE_URL", "POCKETBASE_ADMIN_EMAIL", "POCKETBASE_ADMIN_PASSWORD", + "MINIO_ENDPOINT", "MINIO_PUBLIC_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY", + "MINIO_USE_SSL", "MINIO_PUBLIC_USE_SSL", + "MINIO_BUCKET_CHAPTERS", "MINIO_BUCKET_AUDIO", "MINIO_BUCKET_AVATARS", + "KOKORO_URL", "KOKORO_VOICE", + "BACKEND_HTTP_ADDR", + "RUNNER_POLL_INTERVAL", "RUNNER_MAX_CONCURRENT_SCRAPE", "RUNNER_MAX_CONCURRENT_AUDIO", + "RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT", "SCRAPER_PROXY", + } + for _, k := range unset { + t.Setenv(k, "") + } + + cfg := config.Load() + + if cfg.LogLevel != "info" { + t.Errorf("LogLevel: want info, got %q", cfg.LogLevel) + } + if cfg.PocketBase.URL != "http://localhost:8090" { + t.Errorf("PocketBase.URL: want http://localhost:8090, got %q", cfg.PocketBase.URL) + } + if cfg.MinIO.BucketChapters != "libnovel-chapters" { + t.Errorf("MinIO.BucketChapters: want libnovel-chapters, got %q", cfg.MinIO.BucketChapters) + } + if cfg.MinIO.UseSSL != false { + t.Errorf("MinIO.UseSSL: want false, got %v", cfg.MinIO.UseSSL) + } + if cfg.MinIO.PublicUseSSL != true { + t.Errorf("MinIO.PublicUseSSL: want true, got %v", cfg.MinIO.PublicUseSSL) + } + if cfg.Kokoro.DefaultVoice != "af_bella" { + t.Errorf("Kokoro.DefaultVoice: want af_bella, got %q", cfg.Kokoro.DefaultVoice) + } + if cfg.HTTP.Addr != ":8080" { + t.Errorf("HTTP.Addr: want :8080, got %q", cfg.HTTP.Addr) + } + if cfg.Runner.PollInterval != 30*time.Second { + t.Errorf("Runner.PollInterval: want 30s, got %v", cfg.Runner.PollInterval) + } + if cfg.Runner.MaxConcurrentScrape != 1 { + t.Errorf("Runner.MaxConcurrentScrape: want 1, got %d", cfg.Runner.MaxConcurrentScrape) + } + if cfg.Runner.MaxConcurrentAudio != 1 { + t.Errorf("Runner.MaxConcurrentAudio: want 1, got %d", cfg.Runner.MaxConcurrentAudio) + } +} + +func TestLoad_EnvOverride(t *testing.T) { + t.Setenv("LOG_LEVEL", "debug") + t.Setenv("POCKETBASE_URL", "https://pb.libnovel.cc") + t.Setenv("MINIO_USE_SSL", "true") + t.Setenv("MINIO_PUBLIC_USE_SSL", "false") + t.Setenv("RUNNER_POLL_INTERVAL", "1m") + t.Setenv("RUNNER_MAX_CONCURRENT_SCRAPE", "5") + t.Setenv("RUNNER_WORKER_ID", "homelab-01") + t.Setenv("BACKEND_HTTP_ADDR", ":9090") + t.Setenv("KOKORO_URL", "https://kokoro.libnovel.cc") + + cfg := config.Load() + + if cfg.LogLevel != "debug" { + t.Errorf("LogLevel: want debug, got %q", cfg.LogLevel) + } + if cfg.PocketBase.URL != "https://pb.libnovel.cc" { + t.Errorf("PocketBase.URL: want https://pb.libnovel.cc, got %q", cfg.PocketBase.URL) + } + if !cfg.MinIO.UseSSL { + t.Error("MinIO.UseSSL: want true") + } + if cfg.MinIO.PublicUseSSL { + t.Error("MinIO.PublicUseSSL: want false") + } + if cfg.Runner.PollInterval != time.Minute { + t.Errorf("Runner.PollInterval: want 1m, got %v", cfg.Runner.PollInterval) + } + if cfg.Runner.MaxConcurrentScrape != 5 { + t.Errorf("Runner.MaxConcurrentScrape: want 5, got %d", cfg.Runner.MaxConcurrentScrape) + } + if cfg.Runner.WorkerID != "homelab-01" { + t.Errorf("Runner.WorkerID: want homelab-01, got %q", cfg.Runner.WorkerID) + } + if cfg.HTTP.Addr != ":9090" { + t.Errorf("HTTP.Addr: want :9090, got %q", cfg.HTTP.Addr) + } + if cfg.Kokoro.URL != "https://kokoro.libnovel.cc" { + t.Errorf("Kokoro.URL: want https://kokoro.libnovel.cc, got %q", cfg.Kokoro.URL) + } +} + +func TestLoad_InvalidInt_FallsToDefault(t *testing.T) { + t.Setenv("RUNNER_MAX_CONCURRENT_SCRAPE", "notanumber") + cfg := config.Load() + if cfg.Runner.MaxConcurrentScrape != 1 { + t.Errorf("want default 1, got %d", cfg.Runner.MaxConcurrentScrape) + } +} + +func TestLoad_InvalidDuration_FallsToDefault(t *testing.T) { + t.Setenv("RUNNER_POLL_INTERVAL", "notaduration") + cfg := config.Load() + if cfg.Runner.PollInterval != 30*time.Second { + t.Errorf("want default 30s, got %v", cfg.Runner.PollInterval) + } +} + +func TestLoad_WorkerID_FallsToHostname(t *testing.T) { + t.Setenv("RUNNER_WORKER_ID", "") + cfg := config.Load() + host, _ := os.Hostname() + if host != "" && cfg.Runner.WorkerID != host { + t.Errorf("want hostname %q, got %q", host, cfg.Runner.WorkerID) + } +} diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go new file mode 100644 index 0000000..256c50c --- /dev/null +++ b/backend/internal/domain/domain.go @@ -0,0 +1,131 @@ +// Package domain contains the core value types shared across all packages +// in this module. It has zero internal imports — only the standard library. +// Every other package imports domain; domain imports nothing from this module. +package domain + +import "time" + +// ── Book types ──────────────────────────────────────────────────────────────── + +// BookMeta carries all bibliographic information about a novel. +type BookMeta struct { + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Cover string `json:"cover,omitempty"` + Status string `json:"status,omitempty"` + Genres []string `json:"genres,omitempty"` + Summary string `json:"summary,omitempty"` + TotalChapters int `json:"total_chapters,omitempty"` + SourceURL string `json:"source_url"` + Ranking int `json:"ranking,omitempty"` +} + +// CatalogueEntry is a lightweight book reference returned by catalogue pages. +type CatalogueEntry struct { + Title string `json:"title"` + URL string `json:"url"` +} + +// ChapterRef is a reference to a single chapter returned by chapter-list pages. +type ChapterRef struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Volume int `json:"volume,omitempty"` +} + +// Chapter contains the fully-extracted text of a single chapter. +type Chapter struct { + Ref ChapterRef `json:"ref"` + Text string `json:"text"` +} + +// RankingItem represents a single entry in the novel ranking list. +type RankingItem struct { + Rank int `json:"rank"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author,omitempty"` + Cover string `json:"cover,omitempty"` + Status string `json:"status,omitempty"` + Genres []string `json:"genres,omitempty"` + SourceURL string `json:"source_url,omitempty"` + Updated time.Time `json:"updated,omitempty"` +} + +// ── Storage record types ────────────────────────────────────────────────────── + +// ChapterInfo is a lightweight chapter descriptor stored in the index. +type ChapterInfo struct { + Number int `json:"number"` + Title string `json:"title"` + Date string `json:"date,omitempty"` +} + +// ReadingProgress holds a single user's reading position for one book. +type ReadingProgress struct { + Slug string `json:"slug"` + Chapter int `json:"chapter"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ── Task record types ───────────────────────────────────────────────────────── + +// TaskStatus enumerates the lifecycle states of any task. +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "pending" + TaskStatusRunning TaskStatus = "running" + TaskStatusDone TaskStatus = "done" + TaskStatusFailed TaskStatus = "failed" + TaskStatusCancelled TaskStatus = "cancelled" +) + +// ScrapeTask represents a book-scraping job stored in PocketBase. +type ScrapeTask struct { + ID string `json:"id"` + Kind string `json:"kind"` // "catalogue" | "book" | "book_range" + TargetURL string `json:"target_url"` // non-empty for single-book tasks + FromChapter int `json:"from_chapter,omitempty"` + ToChapter int `json:"to_chapter,omitempty"` + WorkerID string `json:"worker_id,omitempty"` + Status TaskStatus `json:"status"` + BooksFound int `json:"books_found"` + ChaptersScraped int `json:"chapters_scraped"` + ChaptersSkipped int `json:"chapters_skipped"` + Errors int `json:"errors"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask. +type ScrapeResult struct { + BooksFound int `json:"books_found"` + ChaptersScraped int `json:"chapters_scraped"` + ChaptersSkipped int `json:"chapters_skipped"` + Errors int `json:"errors"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// AudioTask represents an audio-generation job stored in PocketBase. +type AudioTask struct { + ID string `json:"id"` + CacheKey string `json:"cache_key"` // "slug/chapter/voice" + Slug string `json:"slug"` + Chapter int `json:"chapter"` + Voice string `json:"voice"` + WorkerID string `json:"worker_id,omitempty"` + Status TaskStatus `json:"status"` + ErrorMessage string `json:"error_message,omitempty"` + Started time.Time `json:"started"` + Finished time.Time `json:"finished,omitempty"` +} + +// AudioResult is the outcome reported by the runner after finishing an AudioTask. +type AudioResult struct { + ObjectKey string `json:"object_key,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} diff --git a/backend/internal/domain/domain_test.go b/backend/internal/domain/domain_test.go new file mode 100644 index 0000000..c364657 --- /dev/null +++ b/backend/internal/domain/domain_test.go @@ -0,0 +1,104 @@ +package domain_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/libnovel/backend/internal/domain" +) + +func TestBookMeta_JSONRoundtrip(t *testing.T) { + orig := domain.BookMeta{ + Slug: "a-great-novel", + Title: "A Great Novel", + Author: "Jane Doe", + Cover: "https://example.com/cover.jpg", + Status: "Ongoing", + Genres: []string{"Fantasy", "Action"}, + Summary: "A thrilling tale.", + TotalChapters: 120, + SourceURL: "https://novelfire.net/book/a-great-novel", + Ranking: 3, + } + + b, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got domain.BookMeta + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Slug != orig.Slug { + t.Errorf("Slug: want %q, got %q", orig.Slug, got.Slug) + } + if got.TotalChapters != orig.TotalChapters { + t.Errorf("TotalChapters: want %d, got %d", orig.TotalChapters, got.TotalChapters) + } + if len(got.Genres) != len(orig.Genres) { + t.Errorf("Genres len: want %d, got %d", len(orig.Genres), len(got.Genres)) + } +} + +func TestChapterRef_JSONRoundtrip(t *testing.T) { + orig := domain.ChapterRef{Number: 42, Title: "The Battle", URL: "https://example.com/ch-42", Volume: 2} + b, _ := json.Marshal(orig) + var got domain.ChapterRef + json.Unmarshal(b, &got) + if got != orig { + t.Errorf("want %+v, got %+v", orig, got) + } +} + +func TestRankingItem_JSONRoundtrip(t *testing.T) { + now := time.Now().Truncate(time.Second) + orig := domain.RankingItem{ + Rank: 1, + Slug: "top-novel", + Title: "Top Novel", + SourceURL: "https://novelfire.net/book/top-novel", + Updated: now, + } + b, _ := json.Marshal(orig) + var got domain.RankingItem + json.Unmarshal(b, &got) + if got.Rank != orig.Rank || got.Slug != orig.Slug { + t.Errorf("want %+v, got %+v", orig, got) + } +} + +func TestScrapeResult_JSONRoundtrip(t *testing.T) { + orig := domain.ScrapeResult{BooksFound: 10, ChaptersScraped: 200, ChaptersSkipped: 5, Errors: 1, ErrorMessage: "one error"} + b, _ := json.Marshal(orig) + var got domain.ScrapeResult + json.Unmarshal(b, &got) + if got != orig { + t.Errorf("want %+v, got %+v", orig, got) + } +} + +func TestAudioResult_JSONRoundtrip(t *testing.T) { + orig := domain.AudioResult{ObjectKey: "audio/slug/1/af_bella.mp3"} + b, _ := json.Marshal(orig) + var got domain.AudioResult + json.Unmarshal(b, &got) + if got != orig { + t.Errorf("want %+v, got %+v", orig, got) + } +} + +func TestTaskStatus_Values(t *testing.T) { + cases := []domain.TaskStatus{ + domain.TaskStatusPending, + domain.TaskStatusRunning, + domain.TaskStatusDone, + domain.TaskStatusFailed, + domain.TaskStatusCancelled, + } + for _, s := range cases { + if s == "" { + t.Errorf("TaskStatus constant must not be empty") + } + } +} diff --git a/backend/internal/httputil/httputil.go b/backend/internal/httputil/httputil.go new file mode 100644 index 0000000..f36359e --- /dev/null +++ b/backend/internal/httputil/httputil.go @@ -0,0 +1,124 @@ +// Package httputil provides shared HTTP helpers used by both the runner and +// backend binaries. It has no imports from this module — only the standard +// library — so it is safe to import from anywhere in the dependency graph. +package httputil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// Client is the minimal interface for making HTTP GET requests. +// *http.Client satisfies this interface. +type Client interface { + Do(req *http.Request) (*http.Response, error) +} + +// ErrMaxRetries is returned when RetryGet exhausts all attempts. +var ErrMaxRetries = errors.New("httputil: max retries exceeded") + +// errClientError is returned by doGet for 4xx responses; it signals that the +// request should NOT be retried (the client is at fault). +var errClientError = errors.New("httputil: client error") + +// RetryGet fetches url using client, retrying on network errors or 5xx +// responses with exponential backoff. It returns the full response body as a +// string on success. +// +// - maxAttempts: total number of attempts (must be >= 1) +// - baseDelay: initial wait before the second attempt; doubles each retry +func RetryGet(ctx context.Context, client Client, url string, maxAttempts int, baseDelay time.Duration) (string, error) { + if maxAttempts < 1 { + maxAttempts = 1 + } + delay := baseDelay + + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(delay): + } + delay *= 2 + } + + body, err := doGet(ctx, client, url) + if err == nil { + return body, nil + } + lastErr = err + + // Do not retry on context cancellation. + if ctx.Err() != nil { + return "", ctx.Err() + } + // Do not retry on 4xx — the client is at fault. + if errors.Is(err, errClientError) { + return "", err + } + } + + return "", fmt.Errorf("%w after %d attempts: %w", ErrMaxRetries, maxAttempts, lastErr) +} + +func doGet(ctx context.Context, client Client, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 { + return "", fmt.Errorf("GET %s: server error %d", url, resp.StatusCode) + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("%w: GET %s: client error %d", errClientError, url, resp.StatusCode) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read body %s: %w", url, err) + } + return string(raw), nil +} + +// WriteJSON writes v as JSON to w with the given HTTP status code and sets the +// Content-Type header to application/json. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// WriteError writes a JSON error object {"error": msg} with the given status. +func WriteError(w http.ResponseWriter, status int, msg string) { + WriteJSON(w, status, map[string]string{"error": msg}) +} + +// maxBodyBytes is the limit applied by DecodeJSON to prevent unbounded reads. +const maxBodyBytes = 1 << 20 // 1 MiB + +// DecodeJSON decodes a JSON request body into v. It enforces a 1 MiB size +// limit and returns a descriptive error on any failure. +func DecodeJSON(r *http.Request, v any) error { + r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return fmt.Errorf("decode JSON body: %w", err) + } + return nil +} diff --git a/backend/internal/httputil/httputil_test.go b/backend/internal/httputil/httputil_test.go new file mode 100644 index 0000000..2af3bba --- /dev/null +++ b/backend/internal/httputil/httputil_test.go @@ -0,0 +1,181 @@ +package httputil_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/libnovel/backend/internal/httputil" +) + +// ── RetryGet ────────────────────────────────────────────────────────────────── + +func TestRetryGet_ImmediateSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello")) + })) + defer srv.Close() + + body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body != "hello" { + t.Errorf("want hello, got %q", body) + } +} + +func TestRetryGet_RetriesOn5xx(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Write([]byte("ok")) + })) + defer srv.Close() + + body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body != "ok" { + t.Errorf("want ok, got %q", body) + } + if calls != 3 { + t.Errorf("want 3 calls, got %d", calls) + } +} + +func TestRetryGet_MaxAttemptsExceeded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestRetryGet_ContextCancelDuringBackoff(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel after first failed attempt hits the backoff wait. + go func() { time.Sleep(5 * time.Millisecond); cancel() }() + + _, err := httputil.RetryGet(ctx, srv.Client(), srv.URL, 10, 500*time.Millisecond) + if err == nil { + t.Fatal("expected context cancellation error") + } +} + +func TestRetryGet_NoRetryOn4xx(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond) + if err == nil { + t.Fatal("expected error for 404") + } + // 4xx is NOT retried — should be exactly 1 call. + if calls != 1 { + t.Errorf("want 1 call for 4xx, got %d", calls) + } +} + +// ── WriteJSON ───────────────────────────────────────────────────────────────── + +func TestWriteJSON_SetsHeadersAndStatus(t *testing.T) { + rr := httptest.NewRecorder() + httputil.WriteJSON(rr, http.StatusCreated, map[string]string{"key": "val"}) + + if rr.Code != http.StatusCreated { + t.Errorf("status: want 201, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type: want application/json, got %q", ct) + } + var got map[string]string + if err := json.NewDecoder(rr.Body).Decode(&got); err != nil { + t.Fatalf("decode body: %v", err) + } + if got["key"] != "val" { + t.Errorf("body key: want val, got %q", got["key"]) + } +} + +// ── WriteError ──────────────────────────────────────────────────────────────── + +func TestWriteError_Format(t *testing.T) { + rr := httptest.NewRecorder() + httputil.WriteError(rr, http.StatusBadRequest, "bad input") + + if rr.Code != http.StatusBadRequest { + t.Errorf("status: want 400, got %d", rr.Code) + } + var got map[string]string + json.NewDecoder(rr.Body).Decode(&got) + if got["error"] != "bad input" { + t.Errorf("error field: want bad input, got %q", got["error"]) + } +} + +// ── DecodeJSON ──────────────────────────────────────────────────────────────── + +func TestDecodeJSON_HappyPath(t *testing.T) { + body := `{"name":"test","value":42}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + var payload struct { + Name string `json:"name"` + Value int `json:"value"` + } + if err := httputil.DecodeJSON(req, &payload); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload.Name != "test" || payload.Value != 42 { + t.Errorf("unexpected payload: %+v", payload) + } +} + +func TestDecodeJSON_UnknownFieldReturnsError(t *testing.T) { + body := `{"name":"test","unknown_field":"boom"}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + + var payload struct { + Name string `json:"name"` + } + if err := httputil.DecodeJSON(req, &payload); err == nil { + t.Fatal("expected error for unknown field, got nil") + } +} + +func TestDecodeJSON_BodyTooLarge(t *testing.T) { + // Build a body > 1 MiB. + big := bytes.Repeat([]byte("a"), 2<<20) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(big)) + + var payload map[string]any + if err := httputil.DecodeJSON(req, &payload); err == nil { + t.Fatal("expected error for oversized body, got nil") + } +} diff --git a/backend/internal/kokoro/client.go b/backend/internal/kokoro/client.go new file mode 100644 index 0000000..6384187 --- /dev/null +++ b/backend/internal/kokoro/client.go @@ -0,0 +1,160 @@ +// Package kokoro provides a client for the Kokoro-FastAPI TTS service. +// +// The Kokoro API is an OpenAI-compatible audio speech API that returns a +// download link (X-Download-Path header) instead of streaming audio directly. +// GenerateAudio handles the two-step flow: POST /v1/audio/speech → GET /v1/download/{file}. +package kokoro + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client is the interface for interacting with the Kokoro TTS service. +type Client interface { + // GenerateAudio synthesises text using voice and returns raw MP3 bytes. + GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) + + // ListVoices returns the available voice IDs. Falls back to an empty slice + // on error — callers should treat an empty list as "service unavailable". + ListVoices(ctx context.Context) ([]string, error) +} + +// httpClient is the concrete Kokoro HTTP client. +type httpClient struct { + baseURL string + http *http.Client +} + +// New returns a Kokoro Client targeting baseURL (e.g. "https://kokoro.example.com"). +func New(baseURL string) Client { + return &httpClient{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &http.Client{Timeout: 10 * time.Minute}, + } +} + +// GenerateAudio calls POST /v1/audio/speech (return_download_link=true) and then +// downloads the resulting MP3 from GET /v1/download/{filename}. +func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) { + if text == "" { + return nil, fmt.Errorf("kokoro: empty text") + } + if voice == "" { + voice = "af_bella" + } + + // ── Step 1: request generation ──────────────────────────────────────────── + reqBody, err := json.Marshal(map[string]any{ + "model": "kokoro", + "input": text, + "voice": voice, + "response_format": "mp3", + "speed": 1.0, + "stream": false, + "return_download_link": true, + }) + if err != nil { + return nil, fmt.Errorf("kokoro: marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("kokoro: build speech request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("kokoro: speech request: %w", err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("kokoro: speech returned %d", resp.StatusCode) + } + + dlPath := resp.Header.Get("X-Download-Path") + if dlPath == "" { + return nil, fmt.Errorf("kokoro: no X-Download-Path header in response") + } + filename := dlPath + if idx := strings.LastIndex(dlPath, "/"); idx >= 0 { + filename = dlPath[idx+1:] + } + if filename == "" { + return nil, fmt.Errorf("kokoro: empty filename in X-Download-Path: %q", dlPath) + } + + // ── Step 2: download the generated file ─────────────────────────────────── + dlURL := c.baseURL + "/v1/download/" + filename + dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, dlURL, nil) + if err != nil { + return nil, fmt.Errorf("kokoro: build download request: %w", err) + } + + dlResp, err := c.http.Do(dlReq) + if err != nil { + return nil, fmt.Errorf("kokoro: download request: %w", err) + } + defer dlResp.Body.Close() + + if dlResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("kokoro: download returned %d", dlResp.StatusCode) + } + + data, err := io.ReadAll(dlResp.Body) + if err != nil { + return nil, fmt.Errorf("kokoro: read download body: %w", err) + } + return data, nil +} + +// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs. +func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.baseURL+"/v1/audio/voices", nil) + if err != nil { + return nil, fmt.Errorf("kokoro: build voices request: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("kokoro: voices request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, fmt.Errorf("kokoro: voices returned %d", resp.StatusCode) + } + + var result struct { + Voices []string `json:"voices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("kokoro: decode voices response: %w", err) + } + return result.Voices, nil +} + +// VoiceSampleKey returns the MinIO object key for a voice sample MP3. +// Key: _voice-samples/{voice}.mp3 (sanitised). +func VoiceSampleKey(voice string) string { + safe := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '_' || r == '-' { + return r + } + return '_' + }, voice) + return fmt.Sprintf("_voice-samples/%s.mp3", safe) +} diff --git a/backend/internal/kokoro/client_test.go b/backend/internal/kokoro/client_test.go new file mode 100644 index 0000000..9d63bbc --- /dev/null +++ b/backend/internal/kokoro/client_test.go @@ -0,0 +1,291 @@ +package kokoro_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/libnovel/backend/internal/kokoro" +) + +// ── VoiceSampleKey ──────────────────────────────────────────────────────────── + +func TestVoiceSampleKey(t *testing.T) { + tests := []struct { + voice string + want string + }{ + {"af_bella", "_voice-samples/af_bella.mp3"}, + {"am_echo", "_voice-samples/am_echo.mp3"}, + {"voice with spaces", "_voice-samples/voice_with_spaces.mp3"}, + {"special!@#chars", "_voice-samples/special___chars.mp3"}, + {"", "_voice-samples/.mp3"}, + } + for _, tt := range tests { + t.Run(tt.voice, func(t *testing.T) { + got := kokoro.VoiceSampleKey(tt.voice) + if got != tt.want { + t.Errorf("VoiceSampleKey(%q) = %q, want %q", tt.voice, got, tt.want) + } + }) + } +} + +// ── GenerateAudio ───────────────────────────────────────────────────────────── + +func TestGenerateAudio_EmptyText(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + c := kokoro.New(srv.URL) + _, err := c.GenerateAudio(context.Background(), "", "af_bella") + if err == nil { + t.Fatal("expected error for empty text, got nil") + } + if !strings.Contains(err.Error(), "empty text") { + t.Errorf("expected 'empty text' in error, got: %v", err) + } +} + +func TestGenerateAudio_DefaultVoice(t *testing.T) { + // Tracks that the voice defaults to af_bella when empty. + var capturedBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/speech" { + buf := make([]byte, 512) + n, _ := r.Body.Read(buf) + capturedBody = string(buf[:n]) + w.Header().Set("X-Download-Path", "/download/test_file.mp3") + w.WriteHeader(http.StatusOK) + return + } + if strings.HasPrefix(r.URL.Path, "/v1/download/") { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("fake-mp3-data")) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + data, err := c.GenerateAudio(context.Background(), "hello world", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "fake-mp3-data" { + t.Errorf("unexpected data: %q", string(data)) + } + if !strings.Contains(capturedBody, `"af_bella"`) { + t.Errorf("expected default voice af_bella in request body, got: %s", capturedBody) + } +} + +func TestGenerateAudio_SpeechNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/speech" { + w.WriteHeader(http.StatusInternalServerError) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + _, err := c.GenerateAudio(context.Background(), "text", "af_bella") + if err == nil { + t.Fatal("expected error for non-200 speech response") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected 500 in error, got: %v", err) + } +} + +func TestGenerateAudio_NoDownloadPathHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/speech" { + // No X-Download-Path header + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + _, err := c.GenerateAudio(context.Background(), "text", "af_bella") + if err == nil { + t.Fatal("expected error for missing X-Download-Path") + } + if !strings.Contains(err.Error(), "X-Download-Path") { + t.Errorf("expected X-Download-Path in error, got: %v", err) + } +} + +func TestGenerateAudio_DownloadFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/speech" { + w.Header().Set("X-Download-Path", "/v1/download/speech.mp3") + w.WriteHeader(http.StatusOK) + return + } + if strings.HasPrefix(r.URL.Path, "/v1/download/") { + w.WriteHeader(http.StatusNotFound) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + _, err := c.GenerateAudio(context.Background(), "text", "af_bella") + if err == nil { + t.Fatal("expected error for failed download") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("expected 404 in error, got: %v", err) + } +} + +func TestGenerateAudio_FullPath(t *testing.T) { + // X-Download-Path with a full path: extract just filename. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/speech" { + w.Header().Set("X-Download-Path", "/some/nested/path/audio_abc123.mp3") + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path == "/v1/download/audio_abc123.mp3" { + _, _ = w.Write([]byte("audio-bytes")) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + data, err := c.GenerateAudio(context.Background(), "text", "af_bella") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "audio-bytes" { + t.Errorf("unexpected data: %q", string(data)) + } +} + +func TestGenerateAudio_ContextCancelled(t *testing.T) { + // Server that hangs — context should cancel before we get a response. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Never respond. + select {} + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + c := kokoro.New(srv.URL) + _, err := c.GenerateAudio(ctx, "text", "af_bella") + if err == nil { + t.Fatal("expected error for cancelled context") + } +} + +// ── ListVoices ──────────────────────────────────────────────────────────────── + +func TestListVoices_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/voices" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"voices":["af_bella","am_adam","bf_emma"]}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + voices, err := c.ListVoices(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(voices) != 3 { + t.Errorf("expected 3 voices, got %d: %v", len(voices), voices) + } + if voices[0] != "af_bella" { + t.Errorf("expected first voice to be af_bella, got %q", voices[0]) + } +} + +func TestListVoices_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + _, err := c.ListVoices(context.Background()) + if err == nil { + t.Fatal("expected error for non-200 response") + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("expected 503 in error, got: %v", err) + } +} + +func TestListVoices_MalformedJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`not-json`)) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + _, err := c.ListVoices(context.Background()) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestListVoices_EmptyVoices(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"voices":[]}`)) + })) + defer srv.Close() + + c := kokoro.New(srv.URL) + voices, err := c.ListVoices(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(voices) != 0 { + t.Errorf("expected 0 voices, got %d", len(voices)) + } +} + +// ── New ─────────────────────────────────────────────────────────────────────── + +func TestNew_TrailingSlashStripped(t *testing.T) { + // Verify that a trailing slash on baseURL doesn't produce double-slash paths. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/audio/voices" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"voices":["af_bella"]}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := kokoro.New(srv.URL + "/") // trailing slash + voices, err := c.ListVoices(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(voices) == 0 { + t.Error("expected at least one voice") + } +} diff --git a/backend/internal/novelfire/htmlutil/htmlutil.go b/backend/internal/novelfire/htmlutil/htmlutil.go new file mode 100644 index 0000000..5f4b0ef --- /dev/null +++ b/backend/internal/novelfire/htmlutil/htmlutil.go @@ -0,0 +1,228 @@ +// Package htmlutil provides helper functions for parsing HTML with +// golang.org/x/net/html and extracting values by Selector descriptors. +package htmlutil + +import ( + "net/url" + "regexp" + "strings" + + "github.com/libnovel/backend/internal/scraper" + "golang.org/x/net/html" +) + +// ResolveURL returns an absolute URL. If href is already absolute it is +// returned unchanged. Otherwise it is resolved against base. +func ResolveURL(base, href string) string { + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + b, err := url.Parse(base) + if err != nil { + return base + href + } + ref, err := url.Parse(href) + if err != nil { + return base + href + } + return b.ResolveReference(ref).String() +} + +// ParseHTML parses raw HTML and returns the root node. +func ParseHTML(raw string) (*html.Node, error) { + return html.Parse(strings.NewReader(raw)) +} + +// selectorMatches reports whether node n matches sel. +func selectorMatches(n *html.Node, sel scraper.Selector) bool { + if n.Type != html.ElementNode { + return false + } + if sel.Tag != "" && n.Data != sel.Tag { + return false + } + if sel.ID != "" { + for _, a := range n.Attr { + if a.Key == "id" && a.Val == sel.ID { + goto checkClass + } + } + return false + } +checkClass: + if sel.Class != "" { + for _, a := range n.Attr { + if a.Key == "class" { + for _, cls := range strings.Fields(a.Val) { + if cls == sel.Class { + goto matched + } + } + } + } + return false + } +matched: + return true +} + +// AttrVal returns the value of attribute key from node n. +func AttrVal(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +// TextContent returns the concatenated text content of all descendant text nodes. +func TextContent(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(cur *html.Node) { + if cur.Type == html.TextNode { + sb.WriteString(cur.Data) + } + for c := cur.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(sb.String()) +} + +// FindFirst returns the first node matching sel within root. +func FindFirst(root *html.Node, sel scraper.Selector) *html.Node { + var found *html.Node + var walk func(*html.Node) bool + walk = func(n *html.Node) bool { + if selectorMatches(n, sel) { + found = n + return true + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if walk(c) { + return true + } + } + return false + } + walk(root) + return found +} + +// FindAll returns all nodes matching sel within root. +func FindAll(root *html.Node, sel scraper.Selector) []*html.Node { + var results []*html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if selectorMatches(n, sel) { + results = append(results, n) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return results +} + +// ExtractText extracts a string value from node n using sel. +// If sel.Attr is set the attribute value is returned; otherwise the inner text. +func ExtractText(n *html.Node, sel scraper.Selector) string { + if sel.Attr != "" { + return AttrVal(n, sel.Attr) + } + return TextContent(n) +} + +// ExtractFirst locates the first match in root and returns its text/attr value. +func ExtractFirst(root *html.Node, sel scraper.Selector) string { + n := FindFirst(root, sel) + if n == nil { + return "" + } + return ExtractText(n, sel) +} + +// ExtractAll locates all matches in root and returns their text/attr values. +func ExtractAll(root *html.Node, sel scraper.Selector) []string { + nodes := FindAll(root, sel) + out := make([]string, 0, len(nodes)) + for _, n := range nodes { + if v := ExtractText(n, sel); v != "" { + out = append(out, v) + } + } + return out +} + +// NodeToMarkdown converts the children of an HTML node to a plain-text/Markdown +// representation suitable for chapter storage. +func NodeToMarkdown(n *html.Node) string { + var sb strings.Builder + nodeToMD(n, &sb) + out := multiBlankLine.ReplaceAllString(sb.String(), "\n\n") + return strings.TrimSpace(out) +} + +var multiBlankLine = regexp.MustCompile(`\n(\s*\n){2,}`) + +var blockElements = map[string]bool{ + "p": true, "div": true, "br": true, "h1": true, "h2": true, + "h3": true, "h4": true, "h5": true, "h6": true, "li": true, + "blockquote": true, "pre": true, "hr": true, +} + +func nodeToMD(n *html.Node, sb *strings.Builder) { + switch n.Type { + case html.TextNode: + sb.WriteString(n.Data) + case html.ElementNode: + tag := n.Data + switch tag { + case "br": + sb.WriteString("\n") + case "hr": + sb.WriteString("\n---\n") + case "h1", "h2", "h3", "h4", "h5", "h6": + level := int(tag[1] - '0') + sb.WriteString("\n" + strings.Repeat("#", level) + " ") + for c := n.FirstChild; c != nil; c = c.NextSibling { + nodeToMD(c, sb) + } + sb.WriteString("\n\n") + return + case "p", "div", "blockquote": + sb.WriteString("\n") + for c := n.FirstChild; c != nil; c = c.NextSibling { + nodeToMD(c, sb) + } + sb.WriteString("\n") + return + case "em", "i": + sb.WriteString("*") + for c := n.FirstChild; c != nil; c = c.NextSibling { + nodeToMD(c, sb) + } + sb.WriteString("*") + return + case "strong", "b": + sb.WriteString("**") + for c := n.FirstChild; c != nil; c = c.NextSibling { + nodeToMD(c, sb) + } + sb.WriteString("**") + return + case "script", "style", "noscript": + return // drop + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + nodeToMD(c, sb) + } + if blockElements[tag] { + sb.WriteString("\n") + } + } +} diff --git a/backend/internal/novelfire/scraper.go b/backend/internal/novelfire/scraper.go new file mode 100644 index 0000000..7122b9a --- /dev/null +++ b/backend/internal/novelfire/scraper.go @@ -0,0 +1,498 @@ +// Package novelfire provides a NovelScraper implementation for novelfire.net. +// +// Site structure (as of 2025): +// +// Catalogue : https://novelfire.net/genre-all/sort-new/status-all/all-novel?page=N +// Book page : https://novelfire.net/book/{slug} +// Chapters : https://novelfire.net/book/{slug}/chapters?page=N +// Chapter : https://novelfire.net/book/{slug}/{chapter-slug} +package novelfire + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/libnovel/backend/internal/browser" + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/novelfire/htmlutil" + "github.com/libnovel/backend/internal/scraper" + "golang.org/x/net/html" +) + +const ( + baseURL = "https://novelfire.net" + cataloguePath = "/genre-all/sort-new/status-all/all-novel" + rankingPath = "/genre-all/sort-popular/status-all/all-novel" +) + +// Scraper is the novelfire.net implementation of scraper.NovelScraper. +type Scraper struct { + client browser.Client + log *slog.Logger +} + +// Compile-time interface check. +var _ scraper.NovelScraper = (*Scraper)(nil) + +// New returns a new novelfire Scraper backed by client. +func New(client browser.Client, log *slog.Logger) *Scraper { + if log == nil { + log = slog.Default() + } + return &Scraper{client: client, log: log} +} + +// SourceName implements NovelScraper. +func (s *Scraper) SourceName() string { return "novelfire.net" } + +// ── CatalogueProvider ───────────────────────────────────────────────────────── + +// ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages. +func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) { + entries := make(chan domain.CatalogueEntry, 64) + errs := make(chan error, 16) + + go func() { + defer close(entries) + defer close(errs) + + pageURL := baseURL + cataloguePath + page := 1 + + for pageURL != "" { + select { + case <-ctx.Done(): + return + default: + } + + s.log.Info("scraping catalogue page", "page", page, "url", pageURL) + raw, err := s.client.GetContent(ctx, pageURL) + if err != nil { + errs <- fmt.Errorf("catalogue page %d: %w", page, err) + return + } + + root, err := htmlutil.ParseHTML(raw) + if err != nil { + errs <- fmt.Errorf("catalogue page %d parse: %w", page, err) + return + } + + cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true}) + if len(cards) == 0 { + s.log.Warn("no novel cards found, stopping pagination", "page", page) + return + } + + for _, card := range cards { + linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a", Attr: "href"}) + titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}) + + var title, href string + if linkNode != nil { + href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) + } + if titleNode != nil { + title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{})) + } + if href == "" || title == "" { + continue + } + + bookURL := resolveURL(baseURL, href) + select { + case <-ctx.Done(): + return + case entries <- domain.CatalogueEntry{Title: title, URL: bookURL}: + } + } + + if !hasNextPageLink(root) { + break + } + nextHref := "" + for _, a := range htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) { + if htmlutil.AttrVal(a, "rel") == "next" { + nextHref = htmlutil.AttrVal(a, "href") + break + } + } + if nextHref == "" { + break + } + pageURL = resolveURL(baseURL, nextHref) + page++ + } + }() + + return entries, errs +} + +// ── MetadataProvider ────────────────────────────────────────────────────────── + +// ScrapeMetadata fetches and parses book metadata from the book's landing page. +func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) { + s.log.Debug("metadata fetch starting", "url", bookURL) + + raw, err := s.client.GetContent(ctx, bookURL) + if err != nil { + return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err) + } + + root, err := htmlutil.ParseHTML(raw) + if err != nil { + return domain.BookMeta{}, fmt.Errorf("metadata parse %s: %w", bookURL, err) + } + + title := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"}) + author := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "author"}) + + var cover string + if fig := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); fig != nil { + cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"}) + if cover != "" && !strings.HasPrefix(cover, "http") { + cover = baseURL + cover + } + } + + status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"}) + + genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"}) + var genres []string + if genresNode != nil { + genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true}) + } + + summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"}) + totalStr := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "chapter-count"}) + totalChapters := parseChapterCount(totalStr) + + slug := slugFromURL(bookURL) + + meta := domain.BookMeta{ + Slug: slug, + Title: title, + Author: author, + Cover: cover, + Status: status, + Genres: genres, + Summary: summary, + TotalChapters: totalChapters, + SourceURL: bookURL, + } + s.log.Debug("metadata parsed", "slug", meta.Slug, "title", meta.Title) + return meta, nil +} + +// ── ChapterListProvider ─────────────────────────────────────────────────────── + +// ScrapeChapterList returns all chapter references for a book, ordered ascending. +func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) { + var refs []domain.ChapterRef + baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters" + page := 1 + + for { + select { + case <-ctx.Done(): + return refs, ctx.Err() + default: + } + + pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page) + s.log.Info("scraping chapter list", "page", page, "url", pageURL) + + raw, err := s.client.GetContent(ctx, pageURL) + if err != nil { + return refs, fmt.Errorf("chapter list page %d: %w", page, err) + } + + root, err := htmlutil.ParseHTML(raw) + if err != nil { + return refs, fmt.Errorf("chapter list page %d parse: %w", page, err) + } + + chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"}) + if chapterList == nil { + s.log.Debug("chapter list container not found, stopping pagination", "page", page) + break + } + + items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"}) + if len(items) == 0 { + break + } + + for _, item := range items { + linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"}) + if linkNode == nil { + continue + } + href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"}) + chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{}) + if href == "" { + continue + } + chURL := resolveURL(baseURL, href) + num := chapterNumberFromURL(chURL) + if num <= 0 { + num = len(refs) + 1 + s.log.Warn("chapter number not parseable from URL, falling back to position", + "url", chURL, "position", num) + } + refs = append(refs, domain.ChapterRef{ + Number: num, + Title: strings.TrimSpace(chTitle), + URL: chURL, + }) + } + + page++ + } + + return refs, nil +} + +// ── ChapterTextProvider ─────────────────────────────────────────────────────── + +// ScrapeChapterText fetches and parses a single chapter page. +func (s *Scraper) ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error) { + s.log.Debug("chapter text fetch starting", "chapter", ref.Number, "url", ref.URL) + + raw, err := retryGet(ctx, s.log, s.client, ref.URL, 9, 6*time.Second) + if err != nil { + return domain.Chapter{}, fmt.Errorf("chapter %d fetch: %w", ref.Number, err) + } + + root, err := htmlutil.ParseHTML(raw) + if err != nil { + return domain.Chapter{}, fmt.Errorf("chapter %d parse: %w", ref.Number, err) + } + + container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"}) + if container == nil { + return domain.Chapter{}, fmt.Errorf("chapter %d: #content container not found in %s", ref.Number, ref.URL) + } + + text := htmlutil.NodeToMarkdown(container) + + s.log.Debug("chapter text parsed", "chapter", ref.Number, "text_bytes", len(text)) + + return domain.Chapter{Ref: ref, Text: text}, nil +} + +// ── RankingProvider ─────────────────────────────────────────────────────────── + +// ScrapeRanking pages through up to maxPages pages of the popular-novels listing. +// maxPages <= 0 means all pages. The caller decides whether to persist items. +func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) { + entries := make(chan domain.BookMeta, 32) + errs := make(chan error, 16) + + go func() { + defer close(entries) + defer close(errs) + + rank := 1 + + for page := 1; maxPages <= 0 || page <= maxPages; page++ { + select { + case <-ctx.Done(): + return + default: + } + + pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page) + s.log.Info("scraping popular ranking page", "page", page, "url", pageURL) + + raw, err := s.client.GetContent(ctx, pageURL) + if err != nil { + errs <- fmt.Errorf("ranking page %d: %w", page, err) + return + } + + root, err := htmlutil.ParseHTML(raw) + if err != nil { + errs <- fmt.Errorf("ranking page %d parse: %w", page, err) + return + } + + cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true}) + if len(cards) == 0 { + break + } + + for _, card := range cards { + linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a"}) + if linkNode == nil { + continue + } + href := htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"}) + bookURL := resolveURL(baseURL, href) + if bookURL == "" { + continue + } + + title := strings.TrimSpace(htmlutil.ExtractFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"})) + if title == "" { + title = strings.TrimSpace(htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "title"})) + } + if title == "" { + continue + } + + var cover string + if fig := htmlutil.FindFirst(card, scraper.Selector{Tag: "figure", Class: "novel-cover"}); fig != nil { + cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "data-src"}) + if cover == "" { + cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"}) + } + if strings.HasPrefix(cover, "data:") { + cover = "" + } + if cover != "" && !strings.HasPrefix(cover, "http") { + cover = baseURL + cover + } + } + + meta := domain.BookMeta{ + Slug: slugFromURL(bookURL), + Title: title, + Cover: cover, + SourceURL: bookURL, + Ranking: rank, + } + rank++ + + select { + case <-ctx.Done(): + return + case entries <- meta: + } + } + + if !hasNextPageLink(root) { + break + } + } + }() + + return entries, errs +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) } + +func hasNextPageLink(root *html.Node) bool { + links := htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) + for _, a := range links { + for _, attr := range a.Attr { + if attr.Key == "rel" && attr.Val == "next" { + return true + } + } + } + return false +} + +func slugFromURL(bookURL string) string { + u, err := url.Parse(bookURL) + if err != nil { + return bookURL + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) >= 2 && parts[0] == "book" { + return parts[1] + } + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +func parseChapterCount(s string) int { + s = strings.ReplaceAll(s, ",", "") + fields := strings.Fields(s) + if len(fields) == 0 { + return 0 + } + n, _ := strconv.Atoi(fields[0]) + return n +} + +func chapterNumberFromURL(chapterURL string) int { + u, err := url.Parse(chapterURL) + if err != nil { + return 0 + } + seg := path.Base(u.Path) + seg = strings.TrimPrefix(seg, "chapter-") + seg = strings.TrimPrefix(seg, "chap-") + seg = strings.TrimPrefix(seg, "ch-") + digits := strings.FieldsFunc(seg, func(r rune) bool { + return r < '0' || r > '9' + }) + if len(digits) == 0 { + return 0 + } + n, _ := strconv.Atoi(digits[0]) + return n +} + +// retryGet calls client.GetContent up to maxAttempts times with exponential backoff. +// If the server returns 429 (ErrRateLimit), the suggested Retry-After delay is used +// instead of the geometric backoff delay. +func retryGet( + ctx context.Context, + log *slog.Logger, + client browser.Client, + pageURL string, + maxAttempts int, + baseDelay time.Duration, +) (string, error) { + var lastErr error + delay := baseDelay + for attempt := 1; attempt <= maxAttempts; attempt++ { + raw, err := client.GetContent(ctx, pageURL) + if err == nil { + return raw, nil + } + lastErr = err + if ctx.Err() != nil { + return "", err + } + if attempt < maxAttempts { + // If the server is rate-limiting us, honour its Retry-After delay. + waitFor := delay + var rlErr *browser.RateLimitError + if errors.As(err, &rlErr) { + waitFor = rlErr.RetryAfter + if log != nil { + log.Warn("rate limited, backing off", + "url", pageURL, "attempt", attempt, "retry_in", waitFor) + } + } else { + if log != nil { + log.Warn("fetch failed, retrying", + "url", pageURL, "attempt", attempt, "retry_in", delay, "err", err) + } + delay *= 2 + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(waitFor): + } + } + } + return "", lastErr +} diff --git a/backend/internal/novelfire/scraper_test.go b/backend/internal/novelfire/scraper_test.go new file mode 100644 index 0000000..04a9f7f --- /dev/null +++ b/backend/internal/novelfire/scraper_test.go @@ -0,0 +1,129 @@ +package novelfire + +import ( + "context" + "testing" +) + +func TestSlugFromURL(t *testing.T) { + cases := []struct { + url string + want string + }{ + {"https://novelfire.net/book/shadow-slave", "shadow-slave"}, + {"https://novelfire.net/book/a-dragon-against-the-whole-world", "a-dragon-against-the-whole-world"}, + {"https://novelfire.net/book/foo/chapter-1", "foo"}, + {"https://novelfire.net/", ""}, + {"not-a-url", "not-a-url"}, + } + for _, c := range cases { + got := slugFromURL(c.url) + if got != c.want { + t.Errorf("slugFromURL(%q) = %q, want %q", c.url, got, c.want) + } + } +} + +func TestChapterNumberFromURL(t *testing.T) { + cases := []struct { + url string + want int + }{ + {"https://novelfire.net/book/shadow-slave/chapter-42", 42}, + {"https://novelfire.net/book/shadow-slave/chapter-1000", 1000}, + {"https://novelfire.net/book/shadow-slave/chap-7", 7}, + {"https://novelfire.net/book/shadow-slave/ch-3", 3}, + {"https://novelfire.net/book/shadow-slave/42", 42}, + {"https://novelfire.net/book/shadow-slave/no-number-here", 0}, + {"not-a-url", 0}, + } + for _, c := range cases { + got := chapterNumberFromURL(c.url) + if got != c.want { + t.Errorf("chapterNumberFromURL(%q) = %d, want %d", c.url, got, c.want) + } + } +} + +func TestParseChapterCount(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"123 Chapters", 123}, + {"1,234 Chapters", 1234}, + {"0", 0}, + {"", 0}, + {"500", 500}, + } + for _, c := range cases { + got := parseChapterCount(c.in) + if got != c.want { + t.Errorf("parseChapterCount(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestRetryGet_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + stub := newStubClient() + stub.setError("https://example.com/page", context.Canceled) + + _, err := retryGet(ctx, nil, stub, "https://example.com/page", 3, 0) + if err == nil { + t.Fatal("expected error on cancelled context") + } +} + +func TestRetryGet_EventualSuccess(t *testing.T) { + stub := newStubClient() + calls := 0 + stub.setFn("https://example.com/page", func() (string, error) { + calls++ + if calls < 3 { + return "", context.DeadlineExceeded + } + return "ok", nil + }) + + got, err := retryGet(context.Background(), nil, stub, "https://example.com/page", 5, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "ok" { + t.Errorf("got %q, want html", got) + } + if calls != 3 { + t.Errorf("expected 3 calls, got %d", calls) + } +} + +// ── minimal stub client for tests ───────────────────────────────────────────── + +type stubClient struct { + errors map[string]error + fns map[string]func() (string, error) +} + +func newStubClient() *stubClient { + return &stubClient{ + errors: make(map[string]error), + fns: make(map[string]func() (string, error)), + } +} + +func (s *stubClient) setError(u string, err error) { s.errors[u] = err } + +func (s *stubClient) setFn(u string, fn func() (string, error)) { s.fns[u] = fn } + +func (s *stubClient) GetContent(_ context.Context, pageURL string) (string, error) { + if fn, ok := s.fns[pageURL]; ok { + return fn() + } + if err, ok := s.errors[pageURL]; ok { + return "", err + } + return "", context.DeadlineExceeded +} diff --git a/backend/internal/orchestrator/orchestrator.go b/backend/internal/orchestrator/orchestrator.go new file mode 100644 index 0000000..d23dbe9 --- /dev/null +++ b/backend/internal/orchestrator/orchestrator.go @@ -0,0 +1,205 @@ +// Package orchestrator coordinates metadata extraction, chapter-list fetching, +// and parallel chapter scraping for a single book. +// +// Design: +// - RunBook scrapes one book (metadata + chapter list + chapter texts) end-to-end. +// - N worker goroutines pull chapter refs from a shared queue and call ScrapeChapterText. +// - The caller (runner poll loop) owns the outer task-claim / finish cycle. +package orchestrator + +import ( + "context" + "fmt" + "log/slog" + "runtime" + "sync" + "sync/atomic" + + "github.com/libnovel/backend/internal/bookstore" + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/scraper" +) + +// Config holds tunable parameters for the orchestrator. +type Config struct { + // Workers is the number of goroutines used to scrape chapters in parallel. + // Defaults to runtime.NumCPU() when 0. + Workers int +} + +// Orchestrator runs a single-book scrape pipeline. +type Orchestrator struct { + novel scraper.NovelScraper + store bookstore.BookWriter + log *slog.Logger + workers int +} + +// New returns a new Orchestrator. +func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log *slog.Logger) *Orchestrator { + if log == nil { + log = slog.Default() + } + workers := cfg.Workers + if workers <= 0 { + workers = runtime.NumCPU() + } + return &Orchestrator{novel: novel, store: store, log: log, workers: workers} +} + +// RunBook scrapes a single book described by task. It handles: +// 1. Metadata scrape + write +// 2. Chapter list scrape + write +// 3. Parallel chapter text scrape + write (worker pool) +// +// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty +// if the run failed at the metadata or chapter-list level. +func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult { + o.log.Info("orchestrator: RunBook starting", + "task_id", task.ID, + "kind", task.Kind, + "url", task.TargetURL, + "workers", o.workers, + ) + + var result domain.ScrapeResult + + if task.TargetURL == "" { + result.ErrorMessage = "task has no target URL" + return result + } + + // ── Step 1: Metadata ────────────────────────────────────────────────────── + meta, err := o.novel.ScrapeMetadata(ctx, task.TargetURL) + if err != nil { + o.log.Error("metadata scrape failed", "url", task.TargetURL, "err", err) + result.ErrorMessage = fmt.Sprintf("metadata: %v", err) + result.Errors++ + return result + } + + if err := o.store.WriteMetadata(ctx, meta); err != nil { + o.log.Error("metadata write failed", "slug", meta.Slug, "err", err) + // non-fatal: continue to chapters + result.Errors++ + } else { + result.BooksFound = 1 + } + + o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title) + + // ── Step 2: Chapter list ────────────────────────────────────────────────── + refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL) + if err != nil { + o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err) + result.ErrorMessage = fmt.Sprintf("chapter list: %v", err) + result.Errors++ + return result + } + + o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs)) + + // Persist chapter refs (without text) so the index exists early. + if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil { + o.log.Warn("chapter refs write failed", "slug", meta.Slug, "err", wErr) + } + + // ── Step 3: Chapter texts (worker pool) ─────────────────────────────────── + type chapterJob struct { + slug string + ref domain.ChapterRef + total int // total chapters to scrape (for progress logging) + } + work := make(chan chapterJob, o.workers*4) + + var scraped, skipped, errors atomic.Int64 + var wg sync.WaitGroup + + for i := 0; i < o.workers; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for job := range work { + select { + case <-ctx.Done(): + return + default: + } + + if o.store.ChapterExists(ctx, job.slug, job.ref) { + o.log.Debug("chapter already exists, skipping", + "slug", job.slug, "chapter", job.ref.Number) + skipped.Add(1) + continue + } + + ch, err := o.novel.ScrapeChapterText(ctx, job.ref) + if err != nil { + o.log.Error("chapter scrape failed", + "slug", job.slug, "chapter", job.ref.Number, "err", err) + errors.Add(1) + continue + } + + if err := o.store.WriteChapter(ctx, job.slug, ch); err != nil { + o.log.Error("chapter write failed", + "slug", job.slug, "chapter", job.ref.Number, "err", err) + errors.Add(1) + continue + } + + n := scraped.Add(1) + // Log a progress summary every 25 chapters scraped. + if n%25 == 0 { + o.log.Info("scraping chapters", + "slug", job.slug, "scraped", n, "total", job.total) + } + } + }(i) + } + + // Count how many chapters will actually be enqueued (for progress logging). + toScrape := 0 + for _, ref := range refs { + if task.FromChapter > 0 && ref.Number < task.FromChapter { + continue + } + if task.ToChapter > 0 && ref.Number > task.ToChapter { + continue + } + toScrape++ + } + + // Enqueue chapter jobs respecting the optional range filter from the task. + for _, ref := range refs { + if task.FromChapter > 0 && ref.Number < task.FromChapter { + skipped.Add(1) + continue + } + if task.ToChapter > 0 && ref.Number > task.ToChapter { + skipped.Add(1) + continue + } + select { + case <-ctx.Done(): + goto drain + case work <- chapterJob{slug: meta.Slug, ref: ref, total: toScrape}: + } + } + +drain: + close(work) + wg.Wait() + + result.ChaptersScraped = int(scraped.Load()) + result.ChaptersSkipped = int(skipped.Load()) + result.Errors += int(errors.Load()) + + o.log.Info("book scrape finished", + "slug", meta.Slug, + "scraped", result.ChaptersScraped, + "skipped", result.ChaptersSkipped, + "errors", result.Errors, + ) + return result +} diff --git a/backend/internal/orchestrator/orchestrator_test.go b/backend/internal/orchestrator/orchestrator_test.go new file mode 100644 index 0000000..b1edaf9 --- /dev/null +++ b/backend/internal/orchestrator/orchestrator_test.go @@ -0,0 +1,210 @@ +package orchestrator + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/libnovel/backend/internal/domain" +) + +// ── stubs ───────────────────────────────────────────────────────────────────── + +type stubScraper struct { + meta domain.BookMeta + metaErr error + refs []domain.ChapterRef + refsErr error + chapters map[int]domain.Chapter + chapErr map[int]error +} + +func (s *stubScraper) SourceName() string { return "stub" } + +func (s *stubScraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) { + ch := make(chan domain.CatalogueEntry) + errs := make(chan error) + close(ch) + close(errs) + return ch, errs +} + +func (s *stubScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) { + return s.meta, s.metaErr +} + +func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) { + return s.refs, s.refsErr +} + +func (s *stubScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) { + if s.chapErr != nil { + if err, ok := s.chapErr[ref.Number]; ok { + return domain.Chapter{}, err + } + } + if s.chapters != nil { + if ch, ok := s.chapters[ref.Number]; ok { + return ch, nil + } + } + return domain.Chapter{Ref: ref, Text: "text"}, nil +} + +func (s *stubScraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) { + ch := make(chan domain.BookMeta) + errs := make(chan error) + close(ch) + close(errs) + return ch, errs +} + +type stubStore struct { + mu sync.Mutex + metaWritten []domain.BookMeta + chaptersWritten []domain.Chapter + existing map[string]bool // "slug:N" → exists + writeMetaErr error +} + +func (s *stubStore) WriteMetadata(_ context.Context, meta domain.BookMeta) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.writeMetaErr != nil { + return s.writeMetaErr + } + s.metaWritten = append(s.metaWritten, meta) + return nil +} + +func (s *stubStore) WriteChapter(_ context.Context, slug string, ch domain.Chapter) error { + s.mu.Lock() + defer s.mu.Unlock() + s.chaptersWritten = append(s.chaptersWritten, ch) + return nil +} + +func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error { + return nil +} + +func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool { + s.mu.Lock() + defer s.mu.Unlock() + key := slug + ":" + string(rune('0'+ref.Number)) + return s.existing[key] +} + +// ── tests ────────────────────────────────────────────────────────────────────── + +func TestRunBook_HappyPath(t *testing.T) { + sc := &stubScraper{ + meta: domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"}, + refs: []domain.ChapterRef{ + {Number: 1, Title: "Ch 1", URL: "https://example.com/book/test-book/chapter-1"}, + {Number: 2, Title: "Ch 2", URL: "https://example.com/book/test-book/chapter-2"}, + {Number: 3, Title: "Ch 3", URL: "https://example.com/book/test-book/chapter-3"}, + }, + } + st := &stubStore{} + o := New(Config{Workers: 2}, sc, st, nil) + + task := domain.ScrapeTask{ + ID: "t1", + Kind: "book", + TargetURL: "https://example.com/book/test-book", + } + + result := o.RunBook(context.Background(), task) + + if result.ErrorMessage != "" { + t.Fatalf("unexpected error: %s", result.ErrorMessage) + } + if result.BooksFound != 1 { + t.Errorf("BooksFound = %d, want 1", result.BooksFound) + } + if result.ChaptersScraped != 3 { + t.Errorf("ChaptersScraped = %d, want 3", result.ChaptersScraped) + } +} + +func TestRunBook_MetadataError(t *testing.T) { + sc := &stubScraper{metaErr: errors.New("404 not found")} + st := &stubStore{} + o := New(Config{Workers: 1}, sc, st, nil) + + result := o.RunBook(context.Background(), domain.ScrapeTask{ + ID: "t2", + TargetURL: "https://example.com/book/missing", + }) + + if result.ErrorMessage == "" { + t.Fatal("expected ErrorMessage to be set") + } + if result.Errors != 1 { + t.Errorf("Errors = %d, want 1", result.Errors) + } +} + +func TestRunBook_ChapterRange(t *testing.T) { + sc := &stubScraper{ + meta: domain.BookMeta{Slug: "range-book", SourceURL: "https://example.com/book/range-book"}, + refs: func() []domain.ChapterRef { + var refs []domain.ChapterRef + for i := 1; i <= 10; i++ { + refs = append(refs, domain.ChapterRef{Number: i, URL: "https://example.com/book/range-book/chapter-" + string(rune('0'+i))}) + } + return refs + }(), + } + st := &stubStore{} + o := New(Config{Workers: 2}, sc, st, nil) + + result := o.RunBook(context.Background(), domain.ScrapeTask{ + ID: "t3", + TargetURL: "https://example.com/book/range-book", + FromChapter: 3, + ToChapter: 7, + }) + + if result.ErrorMessage != "" { + t.Fatalf("unexpected error: %s", result.ErrorMessage) + } + // chapters 3–7 = 5 scraped, chapters 1-2 and 8-10 = 5 skipped + if result.ChaptersScraped != 5 { + t.Errorf("ChaptersScraped = %d, want 5", result.ChaptersScraped) + } + if result.ChaptersSkipped != 5 { + t.Errorf("ChaptersSkipped = %d, want 5", result.ChaptersSkipped) + } +} + +func TestRunBook_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + sc := &stubScraper{ + meta: domain.BookMeta{Slug: "ctx-book", SourceURL: "https://example.com/book/ctx-book"}, + refs: []domain.ChapterRef{ + {Number: 1, URL: "https://example.com/book/ctx-book/chapter-1"}, + }, + } + st := &stubStore{} + o := New(Config{Workers: 1}, sc, st, nil) + + // Should not panic; result may have errors or zero chapters. + result := o.RunBook(ctx, domain.ScrapeTask{ + ID: "t4", + TargetURL: "https://example.com/book/ctx-book", + }) + _ = result +} + +func TestRunBook_EmptyTargetURL(t *testing.T) { + o := New(Config{Workers: 1}, &stubScraper{}, &stubStore{}, nil) + result := o.RunBook(context.Background(), domain.ScrapeTask{ID: "t5"}) + if result.ErrorMessage == "" { + t.Fatal("expected ErrorMessage for empty target URL") + } +} diff --git a/backend/internal/runner/helpers.go b/backend/internal/runner/helpers.go new file mode 100644 index 0000000..e07dd0b --- /dev/null +++ b/backend/internal/runner/helpers.go @@ -0,0 +1,21 @@ +package runner + +import ( + "regexp" + "strings" +) + +// stripMarkdown removes common markdown syntax from src, returning plain text +// suitable for TTS. Mirrors the helper in the scraper's server package. +func stripMarkdown(src string) string { + src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "") + src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "") + src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "") + src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "") + src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1") + src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "") + src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "") + src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "") + src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n") + return strings.TrimSpace(src) +} diff --git a/backend/internal/runner/runner.go b/backend/internal/runner/runner.go new file mode 100644 index 0000000..8a6c18a --- /dev/null +++ b/backend/internal/runner/runner.go @@ -0,0 +1,386 @@ +// Package runner implements the worker loop that polls PocketBase for pending +// scrape and audio tasks, executes them, and reports results back. +// +// Design: +// - Run(ctx) loops on a ticker; each tick claims and dispatches pending tasks. +// - Scrape tasks are dispatched to the Orchestrator (one goroutine per task, +// up to MaxConcurrentScrape). +// - Audio tasks fetch chapter text, call Kokoro, upload to MinIO, and report +// the result back (up to MaxConcurrentAudio goroutines). +// - The runner is stateless between ticks; all state lives in PocketBase. +package runner + +import ( + "context" + "fmt" + "log/slog" + "os" + "sync" + "time" + + "github.com/libnovel/backend/internal/bookstore" + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/orchestrator" + "github.com/libnovel/backend/internal/scraper" + "github.com/libnovel/backend/internal/taskqueue" +) + +// Config tunes the runner behaviour. +type Config struct { + // WorkerID uniquely identifies this runner instance in PocketBase records. + WorkerID string + // PollInterval is how often the runner checks for new tasks. + PollInterval time.Duration + // MaxConcurrentScrape limits simultaneous book-scrape goroutines. + MaxConcurrentScrape int + // MaxConcurrentAudio limits simultaneous audio-generation goroutines. + MaxConcurrentAudio int + // OrchestratorWorkers is the chapter-scraping parallelism inside each book run. + OrchestratorWorkers int + // HeartbeatInterval is how often active tasks PATCH their heartbeat_at + // timestamp to signal they are still alive. Defaults to 30s when 0. + HeartbeatInterval time.Duration + // StaleTaskThreshold is how old a heartbeat must be (or absent) before the + // task is considered orphaned and reset to pending. Defaults to 2m when 0. + StaleTaskThreshold time.Duration +} + +// Dependencies are the external services the runner depends on. +type Dependencies struct { + // Consumer claims tasks from PocketBase. + Consumer taskqueue.Consumer + // BookWriter persists scraped data (used by orchestrator). + BookWriter bookstore.BookWriter + // BookReader reads chapter text for audio generation. + BookReader bookstore.BookReader + // AudioStore persists generated audio and checks key existence. + AudioStore bookstore.AudioStore + // Novel is the scraper implementation. + Novel scraper.NovelScraper + // Kokoro is the TTS client. + Kokoro kokoro.Client + // Log is the structured logger. + Log *slog.Logger +} + +// Runner is the main worker process. +type Runner struct { + cfg Config + deps Dependencies +} + +// New creates a Runner from cfg and deps. +// Any zero/nil field in deps will cause a panic at construction time to fail fast. +func New(cfg Config, deps Dependencies) *Runner { + if cfg.PollInterval <= 0 { + cfg.PollInterval = 30 * time.Second + } + if cfg.MaxConcurrentScrape <= 0 { + cfg.MaxConcurrentScrape = 2 + } + if cfg.MaxConcurrentAudio <= 0 { + cfg.MaxConcurrentAudio = 1 + } + if cfg.WorkerID == "" { + cfg.WorkerID = "runner" + } + if cfg.HeartbeatInterval <= 0 { + cfg.HeartbeatInterval = 30 * time.Second + } + if cfg.StaleTaskThreshold <= 0 { + cfg.StaleTaskThreshold = 2 * time.Minute + } + if deps.Log == nil { + deps.Log = slog.Default() + } + return &Runner{cfg: cfg, deps: deps} +} + +// livenessFile is the path written on every successful poll so that the Docker +// healthcheck (CMD /healthcheck file /tmp/runner.alive ) can verify +// the runner is still making progress. +const livenessFile = "/tmp/runner.alive" + +// touchAlive writes the current UTC time to livenessFile. Errors are logged but +// never fatal — liveness is best-effort and should not crash the runner. +func (r *Runner) touchAlive() { + data := []byte(time.Now().UTC().Format(time.RFC3339)) + if err := os.WriteFile(livenessFile, data, 0o644); err != nil { + r.deps.Log.Warn("runner: failed to write liveness file", "err", err) + } +} + +// Run starts the poll loop, blocking until ctx is cancelled. +// On each tick it claims and executes all available pending tasks. +// Scrape and audio tasks run in separate goroutine pools bounded by +// MaxConcurrentScrape and MaxConcurrentAudio respectively. +func (r *Runner) Run(ctx context.Context) error { + r.deps.Log.Info("runner: starting", + "worker_id", r.cfg.WorkerID, + "poll_interval", r.cfg.PollInterval, + "max_scrape", r.cfg.MaxConcurrentScrape, + "max_audio", r.cfg.MaxConcurrentAudio, + ) + + scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape) + audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio) + var wg sync.WaitGroup + + // Write liveness file immediately so the first healthcheck passes before + // the first poll completes. + r.touchAlive() + + tick := time.NewTicker(r.cfg.PollInterval) + defer tick.Stop() + + // Run one poll immediately on startup, then on each tick. + for { + r.poll(ctx, scrapeSem, audioSem, &wg) + r.touchAlive() + + select { + case <-ctx.Done(): + r.deps.Log.Info("runner: context cancelled, draining active tasks") + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + r.deps.Log.Info("runner: all tasks drained, exiting") + case <-time.After(2 * time.Minute): + r.deps.Log.Warn("runner: drain timeout exceeded, forcing exit") + } + return nil + case <-tick.C: + } + } +} + +// poll claims all available pending tasks and dispatches them to goroutines. +// It claims tasks in a tight loop until no more are available. +func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) { + // ── Reap orphaned tasks ─────────────────────────────────────────────── + if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil { + r.deps.Log.Warn("runner: reap stale tasks failed", "err", err) + } else if n > 0 { + r.deps.Log.Info("runner: reaped stale tasks", "count", n) + } + + // ── Scrape tasks ────────────────────────────────────────────────────── + for { + if ctx.Err() != nil { + return + } + task, ok, err := r.deps.Consumer.ClaimNextScrapeTask(ctx, r.cfg.WorkerID) + if err != nil { + r.deps.Log.Error("runner: ClaimNextScrapeTask failed", "err", err) + break + } + if !ok { + break // queue empty + } + // Acquire semaphore (non-blocking when full — leave task running). + select { + case scrapeSem <- struct{}{}: + default: + // Too many concurrent scrapes — the task stays claimed but we can't + // run it right now. Log and break; the next poll will pick it up if + // still running (it won't be re-claimed while status=running). + r.deps.Log.Warn("runner: scrape semaphore full, will retry next tick", + "task_id", task.ID) + break + } + wg.Add(1) + go func(t domain.ScrapeTask) { + defer wg.Done() + defer func() { <-scrapeSem }() + r.runScrapeTask(ctx, t) + }(task) + } + + // ── Audio tasks ─────────────────────────────────────────────────────── + for { + if ctx.Err() != nil { + return + } + task, ok, err := r.deps.Consumer.ClaimNextAudioTask(ctx, r.cfg.WorkerID) + if err != nil { + r.deps.Log.Error("runner: ClaimNextAudioTask failed", "err", err) + break + } + if !ok { + break // queue empty + } + select { + case audioSem <- struct{}{}: + default: + r.deps.Log.Warn("runner: audio semaphore full, will retry next tick", + "task_id", task.ID) + break + } + wg.Add(1) + go func(t domain.AudioTask) { + defer wg.Done() + defer func() { <-audioSem }() + r.runAudioTask(ctx, t) + }(task) + } +} + +// runScrapeTask executes one scrape task end-to-end and reports the result. +func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) { + log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL) + log.Info("runner: scrape task starting") + + // Heartbeat goroutine: periodically PATCH heartbeat_at so the reaper knows + // this task is still alive. Cancelled when the task finishes. + hbCtx, hbCancel := context.WithCancel(ctx) + defer hbCancel() + go func() { + tick := time.NewTicker(r.cfg.HeartbeatInterval) + defer tick.Stop() + for { + select { + case <-hbCtx.Done(): + return + case <-tick.C: + if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil { + log.Warn("runner: heartbeat failed", "err", err) + } + } + } + }() + + oCfg := orchestrator.Config{Workers: r.cfg.OrchestratorWorkers} + o := orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log) + + var result domain.ScrapeResult + + switch task.Kind { + case "catalogue": + result = r.runCatalogueTask(ctx, task, o, log) + case "book", "book_range": + result = o.RunBook(ctx, task) + default: + result.ErrorMessage = fmt.Sprintf("unknown task kind: %q", task.Kind) + log.Warn("runner: unknown task kind") + } + + if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil { + log.Error("runner: FinishScrapeTask failed", "err", err) + } + log.Info("runner: scrape task finished", + "scraped", result.ChaptersScraped, + "skipped", result.ChaptersSkipped, + "errors", result.Errors, + ) +} + +// runCatalogueTask runs a full catalogue scrape by iterating catalogue entries +// and running a book task for each one. +func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o *orchestrator.Orchestrator, log *slog.Logger) domain.ScrapeResult { + entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx) + var result domain.ScrapeResult + + for entry := range entries { + if ctx.Err() != nil { + break + } + bookTask := domain.ScrapeTask{ + ID: task.ID, + Kind: "book", + TargetURL: entry.URL, + } + bookResult := o.RunBook(ctx, bookTask) + result.BooksFound += bookResult.BooksFound + 1 + result.ChaptersScraped += bookResult.ChaptersScraped + result.ChaptersSkipped += bookResult.ChaptersSkipped + result.Errors += bookResult.Errors + } + + if err := <-errCh; err != nil { + log.Warn("runner: catalogue scrape finished with error", "err", err) + result.Errors++ + if result.ErrorMessage == "" { + result.ErrorMessage = err.Error() + } + } + return result +} + +// runAudioTask executes one audio-generation task: +// 1. Read chapter text from MinIO. +// 2. Call Kokoro to generate audio. +// 3. Upload MP3 to MinIO under the standard audio object key. +// 4. Report result back to PocketBase. +func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) { + log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice) + log.Info("runner: audio task starting") + + // Heartbeat goroutine: periodically PATCH heartbeat_at so the reaper knows + // this task is still alive. Cancelled when the task finishes. + hbCtx, hbCancel := context.WithCancel(ctx) + defer hbCancel() + go func() { + tick := time.NewTicker(r.cfg.HeartbeatInterval) + defer tick.Stop() + for { + select { + case <-hbCtx.Done(): + return + case <-tick.C: + if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil { + log.Warn("runner: heartbeat failed", "err", err) + } + } + } + }() + + fail := func(msg string) { + log.Error("runner: audio task failed", "reason", msg) + result := domain.AudioResult{ErrorMessage: msg} + if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil { + log.Error("runner: FinishAudioTask failed", "err", err) + } + } + + // Step 1: read chapter text. + raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter) + if err != nil { + fail(fmt.Sprintf("read chapter: %v", err)) + return + } + text := stripMarkdown(raw) + if text == "" { + fail("chapter text is empty after stripping markdown") + return + } + + // Step 2: generate audio. + if r.deps.Kokoro == nil { + fail("kokoro client not configured") + return + } + audioData, err := r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice) + if err != nil { + fail(fmt.Sprintf("kokoro generate: %v", err)) + return + } + + // Step 3: upload to MinIO. + key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice) + if err := r.deps.AudioStore.PutAudio(ctx, key, audioData); err != nil { + fail(fmt.Sprintf("put audio: %v", err)) + return + } + + // Step 4: report success. + result := domain.AudioResult{ObjectKey: key} + if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil { + log.Error("runner: FinishAudioTask failed", "err", err) + } + log.Info("runner: audio task finished", "key", key) +} diff --git a/backend/internal/runner/runner_test.go b/backend/internal/runner/runner_test.go new file mode 100644 index 0000000..2fa8888 --- /dev/null +++ b/backend/internal/runner/runner_test.go @@ -0,0 +1,365 @@ +package runner_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/runner" +) + +// ── Stub types ──────────────────────────────────────────────────────────────── + +// stubConsumer is a test double for taskqueue.Consumer. +type stubConsumer struct { + scrapeQueue []domain.ScrapeTask + audioQueue []domain.AudioTask + scrapeIdx int + audioIdx int + finished []string + failCalled []string + claimErr error +} + +func (s *stubConsumer) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) { + if s.claimErr != nil { + return domain.ScrapeTask{}, false, s.claimErr + } + if s.scrapeIdx >= len(s.scrapeQueue) { + return domain.ScrapeTask{}, false, nil + } + t := s.scrapeQueue[s.scrapeIdx] + s.scrapeIdx++ + return t, true, nil +} + +func (s *stubConsumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) { + if s.claimErr != nil { + return domain.AudioTask{}, false, s.claimErr + } + if s.audioIdx >= len(s.audioQueue) { + return domain.AudioTask{}, false, nil + } + t := s.audioQueue[s.audioIdx] + s.audioIdx++ + return t, true, nil +} + +func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error { + s.finished = append(s.finished, id) + return nil +} + +func (s *stubConsumer) FinishAudioTask(_ context.Context, id string, _ domain.AudioResult) error { + s.finished = append(s.finished, id) + return nil +} + +func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error { + s.failCalled = append(s.failCalled, id) + return nil +} + +func (s *stubConsumer) HeartbeatTask(_ context.Context, _ string) error { return nil } + +func (s *stubConsumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { + return 0, nil +} + +// stubBookWriter satisfies bookstore.BookWriter (no-op). +type stubBookWriter struct{} + +func (s *stubBookWriter) WriteMetadata(_ context.Context, _ domain.BookMeta) error { return nil } +func (s *stubBookWriter) WriteChapter(_ context.Context, _ string, _ domain.Chapter) error { + return nil +} +func (s *stubBookWriter) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error { + return nil +} +func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.ChapterRef) bool { + return false +} + +// stubBookReader satisfies bookstore.BookReader — returns a single chapter. +type stubBookReader struct { + text string + readErr error +} + +func (s *stubBookReader) ReadChapter(_ context.Context, _ string, _ int) (string, error) { + return s.text, s.readErr +} +func (s *stubBookReader) ReadMetadata(_ context.Context, _ string) (domain.BookMeta, bool, error) { + return domain.BookMeta{}, false, nil +} +func (s *stubBookReader) ListBooks(_ context.Context) ([]domain.BookMeta, error) { return nil, nil } +func (s *stubBookReader) LocalSlugs(_ context.Context) (map[string]bool, error) { return nil, nil } +func (s *stubBookReader) MetadataMtime(_ context.Context, _ string) int64 { return 0 } +func (s *stubBookReader) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) { + return nil, nil +} +func (s *stubBookReader) CountChapters(_ context.Context, _ string) int { return 0 } +func (s *stubBookReader) ReindexChapters(_ context.Context, _ string) (int, error) { + return 0, nil +} + +// stubAudioStore satisfies bookstore.AudioStore. +type stubAudioStore struct { + putCalled atomic.Int32 + putErr error +} + +func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string { + return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3" +} +func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false } +func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error { + s.putCalled.Add(1) + return s.putErr +} + +// stubNovelScraper satisfies scraper.NovelScraper minimally. +type stubNovelScraper struct { + entries []domain.CatalogueEntry + metaErr error + chapters []domain.ChapterRef +} + +func (s *stubNovelScraper) ScrapeCatalogue(_ context.Context) (<-chan domain.CatalogueEntry, <-chan error) { + ch := make(chan domain.CatalogueEntry, len(s.entries)) + errCh := make(chan error, 1) + for _, e := range s.entries { + ch <- e + } + close(ch) + close(errCh) + return ch, errCh +} + +func (s *stubNovelScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) { + if s.metaErr != nil { + return domain.BookMeta{}, s.metaErr + } + return domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"}, nil +} + +func (s *stubNovelScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) { + return s.chapters, nil +} + +func (s *stubNovelScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) { + return domain.Chapter{Ref: ref, Text: "# Chapter\n\nSome text."}, nil +} + +func (s *stubNovelScraper) ScrapeRanking(_ context.Context, _ int) (<-chan domain.BookMeta, <-chan error) { + ch := make(chan domain.BookMeta) + errCh := make(chan error, 1) + close(ch) + close(errCh) + return ch, errCh +} + +func (s *stubNovelScraper) SourceName() string { return "stub" } + +// stubKokoro satisfies kokoro.Client. +type stubKokoro struct { + data []byte + genErr error + called atomic.Int32 +} + +func (s *stubKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) { + s.called.Add(1) + return s.data, s.genErr +} + +func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) { + return []string{"af_bella"}, nil +} + +// ── stripMarkdown helper ────────────────────────────────────────────────────── + +func TestStripMarkdownViaAudioTask(t *testing.T) { + // Verify markdown is stripped before sending to Kokoro. + // We inject chapter text with markdown; the kokoro stub verifies data flows. + consumer := &stubConsumer{ + audioQueue: []domain.AudioTask{ + {ID: "a1", Slug: "book", Chapter: 1, Voice: "af_bella", Status: domain.TaskStatusRunning}, + }, + } + bookReader := &stubBookReader{text: "## Chapter 1\n\nPlain **text** here."} + audioStore := &stubAudioStore{} + kokoroStub := &stubKokoro{data: []byte("mp3")} + + cfg := runner.Config{ + WorkerID: "test", + PollInterval: time.Hour, // long poll — we'll cancel manually + } + deps := runner.Dependencies{ + Consumer: consumer, + BookWriter: &stubBookWriter{}, + BookReader: bookReader, + AudioStore: audioStore, + Novel: &stubNovelScraper{}, + Kokoro: kokoroStub, + } + + r := runner.New(cfg, deps) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = r.Run(ctx) + + if kokoroStub.called.Load() != 1 { + t.Errorf("expected Kokoro.GenerateAudio called once, got %d", kokoroStub.called.Load()) + } + if audioStore.putCalled.Load() != 1 { + t.Errorf("expected PutAudio called once, got %d", audioStore.putCalled.Load()) + } +} + +func TestAudioTask_ReadChapterError(t *testing.T) { + consumer := &stubConsumer{ + audioQueue: []domain.AudioTask{ + {ID: "a2", Slug: "book", Chapter: 2, Voice: "af_bella", Status: domain.TaskStatusRunning}, + }, + } + bookReader := &stubBookReader{readErr: errors.New("chapter not found")} + audioStore := &stubAudioStore{} + kokoroStub := &stubKokoro{data: []byte("mp3")} + + cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour} + deps := runner.Dependencies{ + Consumer: consumer, + BookWriter: &stubBookWriter{}, + BookReader: bookReader, + AudioStore: audioStore, + Novel: &stubNovelScraper{}, + Kokoro: kokoroStub, + } + + r := runner.New(cfg, deps) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = r.Run(ctx) + + // Kokoro should not be called; FinishAudioTask should be called with error. + if kokoroStub.called.Load() != 0 { + t.Errorf("expected Kokoro not called, got %d", kokoroStub.called.Load()) + } + if len(consumer.finished) != 1 { + t.Errorf("expected FinishAudioTask called once, got %d", len(consumer.finished)) + } +} + +func TestAudioTask_KokoroError(t *testing.T) { + consumer := &stubConsumer{ + audioQueue: []domain.AudioTask{ + {ID: "a3", Slug: "book", Chapter: 3, Voice: "af_bella", Status: domain.TaskStatusRunning}, + }, + } + bookReader := &stubBookReader{text: "Chapter text."} + audioStore := &stubAudioStore{} + kokoroStub := &stubKokoro{genErr: errors.New("tts failed")} + + cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour} + deps := runner.Dependencies{ + Consumer: consumer, + BookWriter: &stubBookWriter{}, + BookReader: bookReader, + AudioStore: audioStore, + Novel: &stubNovelScraper{}, + Kokoro: kokoroStub, + } + + r := runner.New(cfg, deps) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = r.Run(ctx) + + if audioStore.putCalled.Load() != 0 { + t.Errorf("expected PutAudio not called, got %d", audioStore.putCalled.Load()) + } + if len(consumer.finished) != 1 { + t.Errorf("expected FinishAudioTask called once, got %d", len(consumer.finished)) + } +} + +func TestScrapeTask_BookKind(t *testing.T) { + consumer := &stubConsumer{ + scrapeQueue: []domain.ScrapeTask{ + {ID: "s1", Kind: "book", TargetURL: "https://example.com/book/test-book", Status: domain.TaskStatusRunning}, + }, + } + + cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour} + deps := runner.Dependencies{ + Consumer: consumer, + BookWriter: &stubBookWriter{}, + BookReader: &stubBookReader{}, + AudioStore: &stubAudioStore{}, + Novel: &stubNovelScraper{}, + Kokoro: &stubKokoro{}, + } + + r := runner.New(cfg, deps) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = r.Run(ctx) + + if len(consumer.finished) != 1 || consumer.finished[0] != "s1" { + t.Errorf("expected task s1 finished, got %v", consumer.finished) + } +} + +func TestScrapeTask_UnknownKind(t *testing.T) { + consumer := &stubConsumer{ + scrapeQueue: []domain.ScrapeTask{ + {ID: "s2", Kind: "unknown_kind", Status: domain.TaskStatusRunning}, + }, + } + + cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour} + deps := runner.Dependencies{ + Consumer: consumer, + BookWriter: &stubBookWriter{}, + BookReader: &stubBookReader{}, + AudioStore: &stubAudioStore{}, + Novel: &stubNovelScraper{}, + Kokoro: &stubKokoro{}, + } + + r := runner.New(cfg, deps) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = r.Run(ctx) + + // Unknown kind still finishes the task (with error message in result). + if len(consumer.finished) != 1 || consumer.finished[0] != "s2" { + t.Errorf("expected task s2 finished, got %v", consumer.finished) + } +} + +func TestRun_CancelImmediately(t *testing.T) { + consumer := &stubConsumer{} + cfg := runner.Config{WorkerID: "test", PollInterval: 10 * time.Millisecond} + deps := runner.Dependencies{ + Consumer: consumer, + BookWriter: &stubBookWriter{}, + BookReader: &stubBookReader{}, + AudioStore: &stubAudioStore{}, + Novel: &stubNovelScraper{}, + Kokoro: &stubKokoro{}, + } + + r := runner.New(cfg, deps) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before Run + + err := r.Run(ctx) + if err != nil { + t.Errorf("expected nil on graceful shutdown, got %v", err) + } +} diff --git a/backend/internal/scraper/scraper.go b/backend/internal/scraper/scraper.go new file mode 100644 index 0000000..bba8f13 --- /dev/null +++ b/backend/internal/scraper/scraper.go @@ -0,0 +1,58 @@ +// Package scraper defines the NovelScraper interface and its sub-interfaces. +// Domain types live in internal/domain — this package only defines the scraping +// contract so that novelfire and any future scrapers can be swapped freely. +package scraper + +import ( + "context" + + "github.com/libnovel/backend/internal/domain" +) + +// CatalogueProvider can enumerate every novel available on a source site. +type CatalogueProvider interface { + ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) +} + +// MetadataProvider can extract structured book metadata from a novel's landing page. +type MetadataProvider interface { + ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) +} + +// ChapterListProvider can enumerate all chapters of a book. +type ChapterListProvider interface { + ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) +} + +// ChapterTextProvider can extract the readable text from a single chapter page. +type ChapterTextProvider interface { + ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error) +} + +// RankingProvider can enumerate novels from a ranking page. +type RankingProvider interface { + // ScrapeRanking pages through up to maxPages ranking pages. + // maxPages <= 0 means all pages. + ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) +} + +// NovelScraper is the full interface a concrete novel source must implement. +type NovelScraper interface { + CatalogueProvider + MetadataProvider + ChapterListProvider + ChapterTextProvider + RankingProvider + + // SourceName returns the human-readable name of this scraper, e.g. "novelfire.net". + SourceName() string +} + +// Selector describes how to locate an element in an HTML document. +type Selector struct { + Tag string + Class string + ID string + Attr string + Multiple bool +} diff --git a/backend/internal/storage/minio.go b/backend/internal/storage/minio.go new file mode 100644 index 0000000..055cdcf --- /dev/null +++ b/backend/internal/storage/minio.go @@ -0,0 +1,194 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/url" + "path" + "strings" + "time" + + minio "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + + "github.com/libnovel/backend/internal/config" +) + +// minioClient wraps the official minio-go client with bucket names. +type minioClient struct { + client *minio.Client // internal — all read/write operations + pubClient *minio.Client // presign-only — initialised against the public endpoint + bucketChapters string + bucketAudio string + bucketAvatars string +} + +func newMinioClient(cfg config.MinIO) (*minioClient, error) { + creds := credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, "") + + internal, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: creds, + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("minio: init internal client: %w", err) + } + + // Presigned URLs must be signed with the hostname the browser will use + // (PUBLIC_MINIO_PUBLIC_URL), because AWS Signature V4 includes the Host + // header in the canonical request — a URL signed against "minio:9000" will + // return SignatureDoesNotMatch when the browser fetches it from + // "localhost:9000". + // + // However, minio-go normally makes a live BucketLocation HTTP call before + // signing, which would fail from inside the container when the public + // endpoint is externally-facing (e.g. "localhost:9000" is unreachable from + // within Docker). We prevent this by: + // 1. Setting Region: "us-east-1" — minio-go skips getBucketLocation when + // the region is already known (bucket-cache.go:49). + // 2. Setting BucketLookup: BucketLookupPath — forces path-style URLs + // (e.g. host/bucket/key), matching MinIO's default behaviour and + // avoiding any virtual-host DNS probing. + // + // When no public endpoint is configured (or it equals the internal one), + // fall back to the internal client so presigning still works. + publicEndpoint := cfg.PublicEndpoint + if u, err2 := url.Parse(publicEndpoint); err2 == nil && u.Host != "" { + publicEndpoint = u.Host // strip scheme so minio.New is happy + } + pubUseSSL := cfg.PublicUseSSL + if publicEndpoint == "" || publicEndpoint == cfg.Endpoint { + publicEndpoint = cfg.Endpoint + pubUseSSL = cfg.UseSSL + } + pub, err := minio.New(publicEndpoint, &minio.Options{ + Creds: creds, + Secure: pubUseSSL, + Region: "us-east-1", // skip live BucketLocation preflight + BucketLookup: minio.BucketLookupPath, + }) + if err != nil { + return nil, fmt.Errorf("minio: init public client: %w", err) + } + + return &minioClient{ + client: internal, + pubClient: pub, + bucketChapters: cfg.BucketChapters, + bucketAudio: cfg.BucketAudio, + bucketAvatars: cfg.BucketAvatars, + }, nil +} + +// ensureBuckets creates all required buckets if they don't already exist. +func (m *minioClient) ensureBuckets(ctx context.Context) error { + for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars} { + exists, err := m.client.BucketExists(ctx, bucket) + if err != nil { + return fmt.Errorf("minio: check bucket %q: %w", bucket, err) + } + if !exists { + if err := m.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { + return fmt.Errorf("minio: create bucket %q: %w", bucket, err) + } + } + } + return nil +} + +// ── Key helpers ─────────────────────────────────────────────────────────────── + +// ChapterObjectKey returns the MinIO object key for a chapter markdown file. +// Format: {slug}/chapter-{n:06d}.md +func ChapterObjectKey(slug string, n int) string { + return fmt.Sprintf("%s/chapter-%06d.md", slug, n) +} + +// AudioObjectKey returns the MinIO object key for a cached audio file. +// Format: {slug}/{n}/{voice}.mp3 +func AudioObjectKey(slug string, n int, voice string) string { + return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice) +} + +// AvatarObjectKey returns the MinIO object key for a user avatar image. +// Format: {userID}/{ext}.{ext} +func AvatarObjectKey(userID, ext string) string { + return fmt.Sprintf("%s/%s.%s", userID, ext, ext) +} + +// chapterNumberFromKey extracts the chapter number from a MinIO object key. +// e.g. "my-book/chapter-000042.md" → 42 +func chapterNumberFromKey(key string) int { + base := path.Base(key) + base = strings.TrimPrefix(base, "chapter-") + base = strings.TrimSuffix(base, ".md") + var n int + fmt.Sscanf(base, "%d", &n) + return n +} + +// ── Object operations ───────────────────────────────────────────────────────── + +func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType string, data []byte) error { + _, err := m.client.PutObject(ctx, bucket, key, + strings.NewReader(string(data)), + int64(len(data)), + minio.PutObjectOptions{ContentType: contentType}, + ) + return err +} + +func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) { + obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{}) + if err != nil { + return nil, err + } + defer obj.Close() + return io.ReadAll(obj) +} + +func (m *minioClient) objectExists(ctx context.Context, bucket, key string) bool { + _, err := m.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}) + return err == nil +} + +func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { + u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil) + if err != nil { + return "", fmt.Errorf("minio presign %s/%s: %w", bucket, key, err) + } + return u.String(), nil +} + +func (m *minioClient) presignPut(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { + u, err := m.pubClient.PresignedPutObject(ctx, bucket, key, expires) + if err != nil { + return "", fmt.Errorf("minio presign PUT %s/%s: %w", bucket, key, err) + } + return u.String(), nil +} + +func (m *minioClient) deleteObjects(ctx context.Context, bucket, prefix string) error { + objCh := m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) + for obj := range objCh { + if obj.Err != nil { + return obj.Err + } + if err := m.client.RemoveObject(ctx, bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil { + return err + } + } + return nil +} + +func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string) ([]string, error) { + var keys []string + for obj := range m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) { + if obj.Err != nil { + return nil, obj.Err + } + keys = append(keys, obj.Key) + } + return keys, nil +} diff --git a/backend/internal/storage/pocketbase.go b/backend/internal/storage/pocketbase.go new file mode 100644 index 0000000..23e1fdc --- /dev/null +++ b/backend/internal/storage/pocketbase.go @@ -0,0 +1,268 @@ +// Package storage provides the concrete implementations of all bookstore and +// taskqueue interfaces backed by PocketBase (structured data) and MinIO (blobs). +// +// Entry point: NewStore(ctx, cfg, log) returns a *Store that satisfies every +// interface defined in bookstore and taskqueue. +package storage + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/libnovel/backend/internal/config" + "github.com/libnovel/backend/internal/domain" +) + +// ErrNotFound is returned by single-record lookups when no record exists. +var ErrNotFound = errors.New("storage: record not found") + +// pbClient is the internal PocketBase REST admin client. +type pbClient struct { + baseURL string + email string + password string + log *slog.Logger + + mu sync.Mutex + token string + exp time.Time +} + +func newPBClient(cfg config.PocketBase, log *slog.Logger) *pbClient { + return &pbClient{ + baseURL: strings.TrimRight(cfg.URL, "/"), + email: cfg.AdminEmail, + password: cfg.AdminPassword, + log: log, + } +} + +// authToken returns a valid admin auth token, refreshing it when expired. +func (c *pbClient) authToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.token != "" && time.Now().Before(c.exp) { + return c.token, nil + } + + body, _ := json.Marshal(map[string]string{ + "identity": c.email, + "password": c.password, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("pb auth: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("pb auth: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("pb auth: status %d: %s", resp.StatusCode, string(raw)) + } + + var payload struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("pb auth: decode: %w", err) + } + c.token = payload.Token + c.exp = time.Now().Add(30 * time.Minute) + return c.token, nil +} + +// do executes an authenticated PocketBase REST request. +func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + tok, err := c.authToken(ctx) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, fmt.Errorf("pb: build request %s %s: %w", method, path, err) + } + req.Header.Set("Authorization", tok) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("pb: %s %s: %w", method, path, err) + } + return resp, nil +} + +// get is a convenience wrapper that decodes a JSON response into v. +func (c *pbClient) get(ctx context.Context, path string, v any) error { + resp, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pb GET %s: status %d: %s", path, resp.StatusCode, string(raw)) + } + return json.NewDecoder(resp.Body).Decode(v) +} + +// post creates a record and decodes the created record into v. +func (c *pbClient) post(ctx context.Context, path string, payload, v any) error { + b, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("pb: marshal: %w", err) + } + resp, err := c.do(ctx, http.MethodPost, path, bytes.NewReader(b)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pb POST %s: status %d: %s", path, resp.StatusCode, string(raw)) + } + if v != nil { + return json.NewDecoder(resp.Body).Decode(v) + } + return nil +} + +// patch updates a record. +func (c *pbClient) patch(ctx context.Context, path string, payload any) error { + b, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("pb: marshal: %w", err) + } + resp, err := c.do(ctx, http.MethodPatch, path, bytes.NewReader(b)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pb PATCH %s: status %d: %s", path, resp.StatusCode, string(raw)) + } + return nil +} + +// delete removes a record. +func (c *pbClient) delete(ctx context.Context, path string) error { + resp, err := c.do(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pb DELETE %s: status %d: %s", path, resp.StatusCode, string(raw)) + } + return nil +} + +// listAll fetches all pages of a collection. PocketBase returns at most 200 +// records per page; we paginate until empty. +func (c *pbClient) listAll(ctx context.Context, collection string, filter, sort string) ([]json.RawMessage, error) { + var all []json.RawMessage + page := 1 + for { + q := url.Values{ + "page": {fmt.Sprintf("%d", page)}, + "perPage": {"200"}, + } + if filter != "" { + q.Set("filter", filter) + } + if sort != "" { + q.Set("sort", sort) + } + path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode()) + + var result struct { + Items []json.RawMessage `json:"items"` + Page int `json:"page"` + Pages int `json:"totalPages"` + } + if err := c.get(ctx, path, &result); err != nil { + return nil, err + } + all = append(all, result.Items...) + if result.Page >= result.Pages { + break + } + page++ + } + return all, nil +} + +// claimRecord atomically claims the first pending record matching collection. +// It fetches the oldest pending record (filter + sort), then PATCHes it with +// the claim payload. Returns (nil, nil) when the queue is empty. +func (c *pbClient) claimRecord(ctx context.Context, collection, workerID string, extraClaim map[string]any) (json.RawMessage, error) { + q := url.Values{} + q.Set("filter", `status="pending"`) + q.Set("sort", "+started") + q.Set("perPage", "1") + path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode()) + + var result struct { + Items []json.RawMessage `json:"items"` + } + if err := c.get(ctx, path, &result); err != nil { + return nil, fmt.Errorf("claimRecord list: %w", err) + } + if len(result.Items) == 0 { + return nil, nil // queue empty + } + + var rec struct { + ID string `json:"id"` + } + if err := json.Unmarshal(result.Items[0], &rec); err != nil { + return nil, fmt.Errorf("claimRecord parse id: %w", err) + } + + claim := map[string]any{ + "status": string(domain.TaskStatusRunning), + "worker_id": workerID, + } + for k, v := range extraClaim { + claim[k] = v + } + + claimPath := fmt.Sprintf("/api/collections/%s/records/%s", collection, rec.ID) + if err := c.patch(ctx, claimPath, claim); err != nil { + return nil, fmt.Errorf("claimRecord patch: %w", err) + } + + // Re-fetch the updated record so caller has current state. + var updated json.RawMessage + if err := c.get(ctx, claimPath, &updated); err != nil { + return nil, fmt.Errorf("claimRecord re-fetch: %w", err) + } + return updated, nil +} diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go new file mode 100644 index 0000000..b8220a0 --- /dev/null +++ b/backend/internal/storage/store.go @@ -0,0 +1,769 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/libnovel/backend/internal/bookstore" + "github.com/libnovel/backend/internal/config" + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/taskqueue" +) + +// Store is the unified persistence implementation that satisfies all bookstore +// and taskqueue interfaces. It routes structured data to PocketBase and binary +// blobs to MinIO. +type Store struct { + pb *pbClient + mc *minioClient + log *slog.Logger +} + +// NewStore initialises PocketBase and MinIO connections and ensures all MinIO +// buckets exist. Returns a ready-to-use Store. +func NewStore(ctx context.Context, cfg config.Config, log *slog.Logger) (*Store, error) { + pb := newPBClient(cfg.PocketBase, log) + // Validate PocketBase connectivity by fetching an auth token. + if _, err := pb.authToken(ctx); err != nil { + return nil, fmt.Errorf("pocketbase: %w", err) + } + + mc, err := newMinioClient(cfg.MinIO) + if err != nil { + return nil, fmt.Errorf("minio: %w", err) + } + if err := mc.ensureBuckets(ctx); err != nil { + return nil, fmt.Errorf("minio: ensure buckets: %w", err) + } + + return &Store{pb: pb, mc: mc, log: log}, nil +} + +// Compile-time interface satisfaction. +var _ bookstore.BookWriter = (*Store)(nil) +var _ bookstore.BookReader = (*Store)(nil) +var _ bookstore.RankingStore = (*Store)(nil) +var _ bookstore.AudioStore = (*Store)(nil) +var _ bookstore.PresignStore = (*Store)(nil) +var _ bookstore.ProgressStore = (*Store)(nil) +var _ taskqueue.Producer = (*Store)(nil) +var _ taskqueue.Consumer = (*Store)(nil) +var _ taskqueue.Reader = (*Store)(nil) + +// ── BookWriter ──────────────────────────────────────────────────────────────── + +func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error { + payload := map[string]any{ + "slug": meta.Slug, + "title": meta.Title, + "author": meta.Author, + "cover": meta.Cover, + "status": meta.Status, + "genres": meta.Genres, + "summary": meta.Summary, + "total_chapters": meta.TotalChapters, + "source_url": meta.SourceURL, + "ranking": meta.Ranking, + } + // Upsert via filter: if exists PATCH, otherwise POST. + existing, err := s.getBookBySlug(ctx, meta.Slug) + if err != nil && err != ErrNotFound { + return fmt.Errorf("WriteMetadata: %w", err) + } + if err == ErrNotFound { + return s.pb.post(ctx, "/api/collections/books/records", payload, nil) + } + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload) +} + +func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error { + key := ChapterObjectKey(slug, chapter.Ref.Number) + if err := s.mc.putObject(ctx, s.mc.bucketChapters, key, "text/markdown", []byte(chapter.Text)); err != nil { + return fmt.Errorf("WriteChapter: minio: %w", err) + } + // Upsert the chapters_idx record in PocketBase. + return s.upsertChapterIdx(ctx, slug, chapter.Ref) +} + +func (s *Store) WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error { + for _, ref := range refs { + if err := s.upsertChapterIdx(ctx, slug, ref); err != nil { + s.log.Warn("WriteChapterRefs: upsert failed", "slug", slug, "chapter", ref.Number, "err", err) + } + } + return nil +} + +func (s *Store) ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool { + return s.mc.objectExists(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, ref.Number)) +} + +func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.ChapterRef) error { + payload := map[string]any{ + "slug": slug, + "number": ref.Number, + "title": ref.Title, + } + filter := fmt.Sprintf(`slug=%q&&number=%d`, slug, ref.Number) + items, err := s.pb.listAll(ctx, "chapters_idx", filter, "") + if err != nil && err != ErrNotFound { + return err + } + if len(items) == 0 { + return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil) + } + var rec struct { + ID string `json:"id"` + } + json.Unmarshal(items[0], &rec) + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload) +} + +// ── BookReader ──────────────────────────────────────────────────────────────── + +type pbBook struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Cover string `json:"cover"` + Status string `json:"status"` + Genres []string `json:"genres"` + Summary string `json:"summary"` + TotalChapters int `json:"total_chapters"` + SourceURL string `json:"source_url"` + Ranking int `json:"ranking"` + Updated string `json:"updated"` +} + +func (b pbBook) toDomain() domain.BookMeta { + return domain.BookMeta{ + Slug: b.Slug, + Title: b.Title, + Author: b.Author, + Cover: b.Cover, + Status: b.Status, + Genres: b.Genres, + Summary: b.Summary, + TotalChapters: b.TotalChapters, + SourceURL: b.SourceURL, + Ranking: b.Ranking, + } +} + +func (s *Store) getBookBySlug(ctx context.Context, slug string) (pbBook, error) { + filter := fmt.Sprintf(`slug=%q`, slug) + items, err := s.pb.listAll(ctx, "books", filter, "") + if err != nil { + return pbBook{}, err + } + if len(items) == 0 { + return pbBook{}, ErrNotFound + } + var b pbBook + json.Unmarshal(items[0], &b) + return b, nil +} + +func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error) { + b, err := s.getBookBySlug(ctx, slug) + if err == ErrNotFound { + return domain.BookMeta{}, false, nil + } + if err != nil { + return domain.BookMeta{}, false, err + } + return b.toDomain(), true, nil +} + +func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) { + items, err := s.pb.listAll(ctx, "books", "", "title") + if err != nil { + return nil, err + } + books := make([]domain.BookMeta, 0, len(items)) + for _, raw := range items { + var b pbBook + json.Unmarshal(raw, &b) + books = append(books, b.toDomain()) + } + return books, nil +} + +func (s *Store) LocalSlugs(ctx context.Context) (map[string]bool, error) { + items, err := s.pb.listAll(ctx, "books", "", "") + if err != nil { + return nil, err + } + slugs := make(map[string]bool, len(items)) + for _, raw := range items { + var b struct { + Slug string `json:"slug"` + } + json.Unmarshal(raw, &b) + if b.Slug != "" { + slugs[b.Slug] = true + } + } + return slugs, nil +} + +func (s *Store) MetadataMtime(ctx context.Context, slug string) int64 { + b, err := s.getBookBySlug(ctx, slug) + if err != nil { + return 0 + } + t, err := time.Parse(time.RFC3339, b.Updated) + if err != nil { + return 0 + } + return t.Unix() +} + +func (s *Store) ReadChapter(ctx context.Context, slug string, n int) (string, error) { + data, err := s.mc.getObject(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, n)) + if err != nil { + return "", fmt.Errorf("ReadChapter: %w", err) + } + return string(data), nil +} + +func (s *Store) ListChapters(ctx context.Context, slug string) ([]domain.ChapterInfo, error) { + filter := fmt.Sprintf(`slug=%q`, slug) + items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number") + if err != nil { + return nil, err + } + chapters := make([]domain.ChapterInfo, 0, len(items)) + for _, raw := range items { + var rec struct { + Number int `json:"number"` + Title string `json:"title"` + } + json.Unmarshal(raw, &rec) + chapters = append(chapters, domain.ChapterInfo{Number: rec.Number, Title: rec.Title}) + } + return chapters, nil +} + +func (s *Store) CountChapters(ctx context.Context, slug string) int { + chapters, err := s.ListChapters(ctx, slug) + if err != nil { + return 0 + } + return len(chapters) +} + +func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) { + keys, err := s.mc.listObjectKeys(ctx, s.mc.bucketChapters, slug+"/") + if err != nil { + return 0, fmt.Errorf("ReindexChapters: list objects: %w", err) + } + count := 0 + for _, key := range keys { + if !strings.HasSuffix(key, ".md") { + continue + } + n := chapterNumberFromKey(key) + if n == 0 { + continue + } + ref := domain.ChapterRef{Number: n} + if err := s.upsertChapterIdx(ctx, slug, ref); err != nil { + s.log.Warn("ReindexChapters: upsert failed", "key", key, "err", err) + continue + } + count++ + } + return count, nil +} + +// ── RankingStore ────────────────────────────────────────────────────────────── + +func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error { + payload := map[string]any{ + "rank": item.Rank, + "slug": item.Slug, + "title": item.Title, + "author": item.Author, + "cover": item.Cover, + "status": item.Status, + "genres": item.Genres, + "source_url": item.SourceURL, + } + filter := fmt.Sprintf(`slug=%q`, item.Slug) + items, err := s.pb.listAll(ctx, "ranking", filter, "") + if err != nil && err != ErrNotFound { + return err + } + if len(items) == 0 { + return s.pb.post(ctx, "/api/collections/ranking/records", payload, nil) + } + var rec struct { + ID string `json:"id"` + } + json.Unmarshal(items[0], &rec) + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ranking/records/%s", rec.ID), payload) +} + +func (s *Store) ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error) { + items, err := s.pb.listAll(ctx, "ranking", "", "rank") + if err != nil { + return nil, err + } + result := make([]domain.RankingItem, 0, len(items)) + for _, raw := range items { + var rec struct { + Rank int `json:"rank"` + Slug string `json:"slug"` + Title string `json:"title"` + Author string `json:"author"` + Cover string `json:"cover"` + Status string `json:"status"` + Genres []string `json:"genres"` + SourceURL string `json:"source_url"` + Updated string `json:"updated"` + } + json.Unmarshal(raw, &rec) + t, _ := time.Parse(time.RFC3339, rec.Updated) + result = append(result, domain.RankingItem{ + Rank: rec.Rank, + Slug: rec.Slug, + Title: rec.Title, + Author: rec.Author, + Cover: rec.Cover, + Status: rec.Status, + Genres: rec.Genres, + SourceURL: rec.SourceURL, + Updated: t, + }) + } + return result, nil +} + +func (s *Store) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) { + items, err := s.ReadRankingItems(ctx) + if err != nil || len(items) == 0 { + return false, err + } + var latest time.Time + for _, item := range items { + if item.Updated.After(latest) { + latest = item.Updated + } + } + return time.Since(latest) < maxAge, nil +} + +// ── AudioStore ──────────────────────────────────────────────────────────────── + +func (s *Store) AudioObjectKey(slug string, n int, voice string) string { + return AudioObjectKey(slug, n, voice) +} + +func (s *Store) AudioExists(ctx context.Context, key string) bool { + return s.mc.objectExists(ctx, s.mc.bucketAudio, key) +} + +func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error { + return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data) +} + +// ── PresignStore ────────────────────────────────────────────────────────────── + +func (s *Store) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) { + return s.mc.presignGet(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, n), expires) +} + +func (s *Store) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) { + return s.mc.presignGet(ctx, s.mc.bucketAudio, key, expires) +} + +func (s *Store) PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) { + key = AvatarObjectKey(userID, ext) + uploadURL, err = s.mc.presignPut(ctx, s.mc.bucketAvatars, key, 15*time.Minute) + return +} + +func (s *Store) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) { + for _, ext := range []string{"jpg", "png", "webp"} { + key := AvatarObjectKey(userID, ext) + if s.mc.objectExists(ctx, s.mc.bucketAvatars, key) { + u, err := s.mc.presignGet(ctx, s.mc.bucketAvatars, key, 1*time.Hour) + return u, true, err + } + } + return "", false, nil +} + +func (s *Store) DeleteAvatar(ctx context.Context, userID string) error { + return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/") +} + +// ── ProgressStore ───────────────────────────────────────────────────────────── + +func (s *Store) GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool) { + filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, slug) + items, err := s.pb.listAll(ctx, "progress", filter, "") + if err != nil || len(items) == 0 { + return domain.ReadingProgress{}, false + } + var rec struct { + Slug string `json:"slug"` + Chapter int `json:"chapter"` + UpdatedAt string `json:"updated"` + } + json.Unmarshal(items[0], &rec) + t, _ := time.Parse(time.RFC3339, rec.UpdatedAt) + return domain.ReadingProgress{Slug: rec.Slug, Chapter: rec.Chapter, UpdatedAt: t}, true +} + +func (s *Store) SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error { + payload := map[string]any{ + "session_id": sessionID, + "slug": p.Slug, + "chapter": p.Chapter, + } + filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, p.Slug) + items, err := s.pb.listAll(ctx, "progress", filter, "") + if err != nil && err != ErrNotFound { + return err + } + if len(items) == 0 { + return s.pb.post(ctx, "/api/collections/progress/records", payload, nil) + } + var rec struct { + ID string `json:"id"` + } + json.Unmarshal(items[0], &rec) + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/progress/records/%s", rec.ID), payload) +} + +func (s *Store) AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error) { + filter := fmt.Sprintf(`session_id=%q`, sessionID) + items, err := s.pb.listAll(ctx, "progress", filter, "-updated") + if err != nil { + return nil, err + } + result := make([]domain.ReadingProgress, 0, len(items)) + for _, raw := range items { + var rec struct { + Slug string `json:"slug"` + Chapter int `json:"chapter"` + UpdatedAt string `json:"updated"` + } + json.Unmarshal(raw, &rec) + t, _ := time.Parse(time.RFC3339, rec.UpdatedAt) + result = append(result, domain.ReadingProgress{Slug: rec.Slug, Chapter: rec.Chapter, UpdatedAt: t}) + } + return result, nil +} + +func (s *Store) DeleteProgress(ctx context.Context, sessionID, slug string) error { + filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, slug) + items, err := s.pb.listAll(ctx, "progress", filter, "") + if err != nil || len(items) == 0 { + return nil + } + var rec struct { + ID string `json:"id"` + } + json.Unmarshal(items[0], &rec) + return s.pb.delete(ctx, fmt.Sprintf("/api/collections/progress/records/%s", rec.ID)) +} + +// ── taskqueue.Producer ──────────────────────────────────────────────────────── + +func (s *Store) CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error) { + payload := map[string]any{ + "kind": kind, + "target_url": targetURL, + "from_chapter": fromChapter, + "to_chapter": toChapter, + "status": string(domain.TaskStatusPending), + "started": time.Now().UTC().Format(time.RFC3339), + } + var rec struct { + ID string `json:"id"` + } + if err := s.pb.post(ctx, "/api/collections/scraping_tasks/records", payload, &rec); err != nil { + return "", err + } + return rec.ID, nil +} + +func (s *Store) CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error) { + cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice) + payload := map[string]any{ + "cache_key": cacheKey, + "slug": slug, + "chapter": chapter, + "voice": voice, + "status": string(domain.TaskStatusPending), + "started": time.Now().UTC().Format(time.RFC3339), + } + var rec struct { + ID string `json:"id"` + } + if err := s.pb.post(ctx, "/api/collections/audio_jobs/records", payload, &rec); err != nil { + return "", err + } + return rec.ID, nil +} + +func (s *Store) CancelTask(ctx context.Context, id string) error { + // Try scraping_tasks first, then audio_jobs. + if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), + map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil { + return nil + } + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), + map[string]string{"status": string(domain.TaskStatusCancelled)}) +} + +// ── taskqueue.Consumer ──────────────────────────────────────────────────────── + +func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) { + raw, err := s.pb.claimRecord(ctx, "scraping_tasks", workerID, nil) + if err != nil { + return domain.ScrapeTask{}, false, err + } + if raw == nil { + return domain.ScrapeTask{}, false, nil + } + task, err := parseScrapeTask(raw) + return task, err == nil, err +} + +func (s *Store) ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error) { + raw, err := s.pb.claimRecord(ctx, "audio_jobs", workerID, nil) + if err != nil { + return domain.AudioTask{}, false, err + } + if raw == nil { + return domain.AudioTask{}, false, nil + } + task, err := parseAudioTask(raw) + return task, err == nil, err +} + +func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error { + status := string(domain.TaskStatusDone) + if result.ErrorMessage != "" { + status = string(domain.TaskStatusFailed) + } + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), map[string]any{ + "status": status, + "books_found": result.BooksFound, + "chapters_scraped": result.ChaptersScraped, + "chapters_skipped": result.ChaptersSkipped, + "errors": result.Errors, + "error_message": result.ErrorMessage, + "finished": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *Store) FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error { + status := string(domain.TaskStatusDone) + if result.ErrorMessage != "" { + status = string(domain.TaskStatusFailed) + } + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), map[string]any{ + "status": status, + "error_message": result.ErrorMessage, + "finished": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (s *Store) FailTask(ctx context.Context, id, errMsg string) error { + payload := map[string]any{ + "status": string(domain.TaskStatusFailed), + "error_message": errMsg, + "finished": time.Now().UTC().Format(time.RFC3339), + } + if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil { + return nil + } + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload) +} + +// HeartbeatTask updates the heartbeat_at field on a running task. +// Tries scraping_tasks first, then audio_jobs (same pattern as FailTask). +func (s *Store) HeartbeatTask(ctx context.Context, id string) error { + payload := map[string]any{ + "heartbeat_at": time.Now().UTC().Format(time.RFC3339), + } + if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil { + return nil + } + return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload) +} + +// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing +// or older than staleAfter, and resets them to pending so they can be +// re-claimed. Returns the number of tasks reaped. +func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) { + threshold := time.Now().UTC().Add(-staleAfter).Format(time.RFC3339) + // Match tasks that are running AND (heartbeat_at is empty OR heartbeat_at < threshold). + filter := fmt.Sprintf(`status="running"&&(heartbeat_at=""||heartbeat_at<"%s")`, threshold) + resetPayload := map[string]any{ + "status": string(domain.TaskStatusPending), + "worker_id": "", + "heartbeat_at": "", + } + + total := 0 + for _, collection := range []string{"scraping_tasks", "audio_jobs"} { + items, err := s.pb.listAll(ctx, collection, filter, "") + if err != nil { + return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err) + } + for _, raw := range items { + var rec struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" { + continue + } + path := fmt.Sprintf("/api/collections/%s/records/%s", collection, rec.ID) + if err := s.pb.patch(ctx, path, resetPayload); err != nil { + s.log.Warn("ReapStaleTasks: patch failed", "collection", collection, "id", rec.ID, "err", err) + continue + } + total++ + } + } + return total, nil +} + +// ── taskqueue.Reader ────────────────────────────────────────────────────────── + +func (s *Store) ListScrapeTasks(ctx context.Context) ([]domain.ScrapeTask, error) { + items, err := s.pb.listAll(ctx, "scraping_tasks", "", "-started") + if err != nil { + return nil, err + } + tasks := make([]domain.ScrapeTask, 0, len(items)) + for _, raw := range items { + t, err := parseScrapeTask(raw) + if err == nil { + tasks = append(tasks, t) + } + } + return tasks, nil +} + +func (s *Store) GetScrapeTask(ctx context.Context, id string) (domain.ScrapeTask, bool, error) { + var raw json.RawMessage + if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), &raw); err != nil { + if err == ErrNotFound { + return domain.ScrapeTask{}, false, nil + } + return domain.ScrapeTask{}, false, err + } + t, err := parseScrapeTask(raw) + return t, err == nil, err +} + +func (s *Store) ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error) { + items, err := s.pb.listAll(ctx, "audio_jobs", "", "-started") + if err != nil { + return nil, err + } + tasks := make([]domain.AudioTask, 0, len(items)) + for _, raw := range items { + t, err := parseAudioTask(raw) + if err == nil { + tasks = append(tasks, t) + } + } + return tasks, nil +} + +func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) { + filter := fmt.Sprintf(`cache_key=%q`, cacheKey) + items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started") + if err != nil || len(items) == 0 { + return domain.AudioTask{}, false, err + } + t, err := parseAudioTask(items[0]) + return t, err == nil, err +} + +// ── Parsers ─────────────────────────────────────────────────────────────────── + +func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) { + var rec struct { + ID string `json:"id"` + Kind string `json:"kind"` + TargetURL string `json:"target_url"` + FromChapter int `json:"from_chapter"` + ToChapter int `json:"to_chapter"` + WorkerID string `json:"worker_id"` + Status string `json:"status"` + BooksFound int `json:"books_found"` + ChaptersScraped int `json:"chapters_scraped"` + ChaptersSkipped int `json:"chapters_skipped"` + Errors int `json:"errors"` + Started string `json:"started"` + Finished string `json:"finished"` + ErrorMessage string `json:"error_message"` + } + if err := json.Unmarshal(raw, &rec); err != nil { + return domain.ScrapeTask{}, err + } + started, _ := time.Parse(time.RFC3339, rec.Started) + finished, _ := time.Parse(time.RFC3339, rec.Finished) + return domain.ScrapeTask{ + ID: rec.ID, + Kind: rec.Kind, + TargetURL: rec.TargetURL, + FromChapter: rec.FromChapter, + ToChapter: rec.ToChapter, + WorkerID: rec.WorkerID, + Status: domain.TaskStatus(rec.Status), + BooksFound: rec.BooksFound, + ChaptersScraped: rec.ChaptersScraped, + ChaptersSkipped: rec.ChaptersSkipped, + Errors: rec.Errors, + Started: started, + Finished: finished, + ErrorMessage: rec.ErrorMessage, + }, nil +} + +func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) { + var rec struct { + ID string `json:"id"` + CacheKey string `json:"cache_key"` + Slug string `json:"slug"` + Chapter int `json:"chapter"` + Voice string `json:"voice"` + WorkerID string `json:"worker_id"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + Started string `json:"started"` + Finished string `json:"finished"` + } + if err := json.Unmarshal(raw, &rec); err != nil { + return domain.AudioTask{}, err + } + started, _ := time.Parse(time.RFC3339, rec.Started) + finished, _ := time.Parse(time.RFC3339, rec.Finished) + return domain.AudioTask{ + ID: rec.ID, + CacheKey: rec.CacheKey, + Slug: rec.Slug, + Chapter: rec.Chapter, + Voice: rec.Voice, + WorkerID: rec.WorkerID, + Status: domain.TaskStatus(rec.Status), + ErrorMessage: rec.ErrorMessage, + Started: started, + Finished: finished, + }, nil +} diff --git a/backend/internal/taskqueue/taskqueue.go b/backend/internal/taskqueue/taskqueue.go new file mode 100644 index 0000000..1ea1a32 --- /dev/null +++ b/backend/internal/taskqueue/taskqueue.go @@ -0,0 +1,84 @@ +// Package taskqueue defines the interfaces for creating and consuming +// scrape/audio tasks stored in PocketBase. +// +// Interface segregation: +// - Producer is used only by the backend (creates tasks, cancels tasks). +// - Consumer is used only by the runner (claims tasks, reports results). +// - Reader is used by the backend for status/history endpoints. +// +// Concrete implementations live in internal/storage. +package taskqueue + +import ( + "context" + "time" + + "github.com/libnovel/backend/internal/domain" +) + +// Producer is the write side of the task queue used by the backend service. +// It creates new tasks in PocketBase for the runner to pick up. +type Producer interface { + // CreateScrapeTask inserts a new scrape task with status=pending and + // returns the assigned PocketBase record ID. + // kind is one of "catalogue", "book", or "book_range". + // targetURL is the book URL (empty for catalogue-wide tasks). + CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error) + + // CreateAudioTask inserts a new audio task with status=pending and + // returns the assigned PocketBase record ID. + CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error) + + // CancelTask transitions a pending task to status=cancelled. + // Returns ErrNotFound if the task does not exist. + CancelTask(ctx context.Context, id string) error +} + +// Consumer is the read/claim side of the task queue used by the runner. +type Consumer interface { + // ClaimNextScrapeTask atomically finds the oldest pending scrape task, + // sets its status=running and worker_id=workerID, and returns it. + // Returns (zero, false, nil) when the queue is empty. + ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) + + // ClaimNextAudioTask atomically finds the oldest pending audio task, + // sets its status=running and worker_id=workerID, and returns it. + // Returns (zero, false, nil) when the queue is empty. + ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error) + + // FinishScrapeTask marks a running scrape task as done and records the result. + FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error + + // FinishAudioTask marks a running audio task as done and records the result. + FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error + + // FailTask marks a task (scrape or audio) as failed with an error message. + FailTask(ctx context.Context, id, errMsg string) error + + // HeartbeatTask updates the heartbeat_at timestamp on a running task. + // Should be called periodically by the runner while the task is active so + // the reaper knows the task is still alive. + HeartbeatTask(ctx context.Context, id string) error + + // ReapStaleTasks finds all running tasks whose heartbeat_at is older than + // staleAfter (or was never set) and resets them to pending so they can be + // re-claimed by a healthy runner. Returns the number of tasks reaped. + ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) +} + +// Reader is the read-only side used by the backend for status pages. +type Reader interface { + // ListScrapeTasks returns all scrape tasks sorted by started descending. + ListScrapeTasks(ctx context.Context) ([]domain.ScrapeTask, error) + + // GetScrapeTask returns a single scrape task by ID. + // Returns (zero, false, nil) if not found. + GetScrapeTask(ctx context.Context, id string) (domain.ScrapeTask, bool, error) + + // ListAudioTasks returns all audio tasks sorted by started descending. + ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error) + + // GetAudioTask returns the most recent audio task for cacheKey. + // Returns (zero, false, nil) if not found. + GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) +} diff --git a/backend/internal/taskqueue/taskqueue_test.go b/backend/internal/taskqueue/taskqueue_test.go new file mode 100644 index 0000000..4b3eb17 --- /dev/null +++ b/backend/internal/taskqueue/taskqueue_test.go @@ -0,0 +1,138 @@ +package taskqueue_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/taskqueue" +) + +// ── Compile-time interface satisfaction ─────────────────────────────────────── + +// stubStore satisfies all three taskqueue interfaces. +// Any method that is called but not expected panics — making accidental +// calls immediately visible in tests. +type stubStore struct{} + +func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (string, error) { + return "task-1", nil +} +func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) { + return "audio-1", nil +} +func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil } + +func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) { + return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil +} +func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) { + return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil +} +func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error { + return nil +} +func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error { + return nil +} +func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil } + +func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil } + +func (s *stubStore) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { + return 0, nil +} + +func (s *stubStore) ListScrapeTasks(_ context.Context) ([]domain.ScrapeTask, error) { return nil, nil } +func (s *stubStore) GetScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) { + return domain.ScrapeTask{}, false, nil +} +func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error) { return nil, nil } +func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) { + return domain.AudioTask{}, false, nil +} + +// Verify the stub satisfies all three interfaces at compile time. +var _ taskqueue.Producer = (*stubStore)(nil) +var _ taskqueue.Consumer = (*stubStore)(nil) +var _ taskqueue.Reader = (*stubStore)(nil) + +// ── Behavioural tests (using stub) ──────────────────────────────────────────── + +func TestProducer_CreateScrapeTask(t *testing.T) { + var p taskqueue.Producer = &stubStore{} + id, err := p.CreateScrapeTask(context.Background(), "book", "https://example.com/book/slug", 0, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id == "" { + t.Error("expected non-empty task ID") + } +} + +func TestConsumer_ClaimNextScrapeTask(t *testing.T) { + var c taskqueue.Consumer = &stubStore{} + task, ok, err := c.ClaimNextScrapeTask(context.Background(), "worker-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected a task to be claimed") + } + if task.Status != domain.TaskStatusRunning { + t.Errorf("want running, got %q", task.Status) + } +} + +func TestConsumer_ClaimNextAudioTask(t *testing.T) { + var c taskqueue.Consumer = &stubStore{} + task, ok, err := c.ClaimNextAudioTask(context.Background(), "worker-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected an audio task to be claimed") + } + if task.ID == "" { + t.Error("expected non-empty task ID") + } +} + +// ── domain.ScrapeResult / domain.AudioResult JSON shape ────────────────────── + +func TestScrapeResult_JSONRoundtrip(t *testing.T) { + cases := []domain.ScrapeResult{ + {BooksFound: 5, ChaptersScraped: 100, ChaptersSkipped: 2, Errors: 0}, + {BooksFound: 0, ChaptersScraped: 0, Errors: 1, ErrorMessage: "timeout"}, + } + for _, orig := range cases { + b, err := json.Marshal(orig) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got domain.ScrapeResult + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got != orig { + t.Errorf("want %+v, got %+v", orig, got) + } + } +} + +func TestAudioResult_JSONRoundtrip(t *testing.T) { + cases := []domain.AudioResult{ + {ObjectKey: "audio/slug/1/af_bella.mp3"}, + {ErrorMessage: "kokoro unavailable"}, + } + for _, orig := range cases { + b, _ := json.Marshal(orig) + var got domain.AudioResult + json.Unmarshal(b, &got) + if got != orig { + t.Errorf("want %+v, got %+v", orig, got) + } + } +} diff --git a/backend/todos.md b/backend/todos.md new file mode 100644 index 0000000..9447ffc --- /dev/null +++ b/backend/todos.md @@ -0,0 +1,301 @@ +# LibNovel Scraper Rewrite — Project Todos + +## Overview + +Split the monolithic scraper into two separate binaries inside the same Go module: + +| Binary | Command | Location | Responsibility | +|--------|---------|----------|----------------| +| **runner** | `cmd/runner` | Homelab | Polls remote PB for pending scrape tasks → scrapes novelfire.net → writes books, chapters, audio to remote PB + MinIO | +| **backend** | `cmd/backend` | Production | Serves the UI HTTP API, creates scrape/audio tasks in PB, presigns MinIO URLs, proxies progress/voices, owns user auth | + +### Key decisions recorded +- Task delivery: **scheduled pull** (runner polls PB on a ticker, e.g. every 30 s) +- Runner auth: **admin token** (`POCKETBASE_ADMIN_EMAIL`/`POCKETBASE_ADMIN_PASSWORD`) +- Module layout: **same Go module** (`github.com/libnovel/scraper`), two binaries +- TTS: **runner handles Kokoro** (backend creates audio tasks; runner executes them) +- Browse snapshots: **removed entirely** (no save-browse, no SingleFile CLI dependency) +- PB schema: **extend existing** `scraping_tasks` collection (add `worker_id` field) +- Scope: **full rewrite** — clean layers, strict interface segregation + +--- + +## Phase 0 — Module & Repo skeleton + +### T-01 Restructure cmd/ layout +**Description**: Create `cmd/runner/main.go` and `cmd/backend/main.go` entry points. Remove the old `cmd/scraper/` entry point (or keep temporarily as a stub). Update `go.mod` module path if needed. +**Unit tests**: `cmd/runner/main_test.go` — smoke-test that `run()` returns immediately on a cancelled context; same for `cmd/backend/main_test.go`. +**Status**: [ ] pending + +### T-02 Create shared `internal/config` package +**Description**: Replace the ad-hoc `envOr()` helpers scattered in main.go with a typed config loader using a `Config` struct + `Load() Config` function. Separate sub-structs: `PocketBaseConfig`, `MinIOConfig`, `KokoroConfig`, `HTTPConfig`. Each binary calls `config.Load()`. +**Unit tests**: `internal/config/config_test.go` — verify defaults, env override for each field, zero-value safety. +**Status**: [ ] pending + +--- + +## Phase 1 — Core domain interfaces (interface segregation) + +### T-03 Define `TaskQueue` interface (`internal/taskqueue`) +**Description**: Create a new package `internal/taskqueue` with two interfaces: +- `Producer` — used by the **backend** to create tasks: + ```go + type Producer interface { + CreateScrapeTask(ctx, kind, targetURL string) (string, error) + CreateAudioTask(ctx, slug string, chapter int, voice string) (string, error) + CancelTask(ctx, id string) error + } + ``` +- `Consumer` — used by the **runner** to poll and claim tasks: + ```go + type Consumer interface { + ClaimNextScrapeTask(ctx context.Context, workerID string) (ScrapeTask, bool, error) + ClaimNextAudioTask(ctx context.Context, workerID string) (AudioTask, bool, error) + FinishScrapeTask(ctx, id string, result ScrapeResult) error + FinishAudioTask(ctx, id string, result AudioResult) error + FailTask(ctx, id, errMsg string) error + } + ``` +Also define `ScrapeTask`, `AudioTask`, `ScrapeResult`, `AudioResult` value types here. +**Unit tests**: `internal/taskqueue/taskqueue_test.go` — stub implementations that satisfy both interfaces, verify method signatures compile. Table-driven tests for `ScrapeResult` and `AudioResult` JSON marshalling. +**Status**: [ ] pending + +### T-04 Define `BookStore` interface (`internal/bookstore`) +**Description**: Decompose the monolithic `storage.Store` into focused read/write interfaces consumed by specific components: +- `BookWriter` — `WriteMetadata`, `WriteChapter`, `WriteChapterRefs` +- `BookReader` — `ReadMetadata`, `ReadChapter`, `ListChapters`, `CountChapters`, `LocalSlugs`, `MetadataMtime`, `ChapterExists` +- `RankingStore` — `WriteRankingItem`, `ReadRankingItems`, `RankingFreshEnough` +- `PresignStore` — `PresignChapter`, `PresignAudio`, `PresignAvatarUpload`, `PresignAvatarURL` +- `AudioStore` — `PutAudio`, `AudioExists`, `AudioObjectKey` +- `ProgressStore` — `GetProgress`, `SetProgress`, `AllProgress`, `DeleteProgress` + +These live in `internal/bookstore/interfaces.go`. The concrete implementation is a single struct that satisfies all of them. The runner only gets `BookWriter + RankingStore + AudioStore`. The backend only gets `BookReader + PresignStore + ProgressStore`. +**Unit tests**: `internal/bookstore/interfaces_test.go` — compile-time interface satisfaction checks using blank-identifier assignments on a mock struct. +**Status**: [ ] pending + +### T-05 Rewrite `internal/scraper/interfaces.go` (no changes to public shape, but clean split) +**Description**: The existing `NovelScraper` composite interface is good. Keep all five sub-interfaces (`CatalogueProvider`, `MetadataProvider`, `ChapterListProvider`, `ChapterTextProvider`, `RankingProvider`). Ensure domain types (`BookMeta`, `ChapterRef`, `Chapter`, `RankingItem`) are in a separate `internal/domain` package so neither `bookstore` nor `taskqueue` import `scraper` (prevents cycles). +**Unit tests**: `internal/domain/domain_test.go` — JSON roundtrip tests for `BookMeta`, `ChapterRef`, `Chapter`, `RankingItem`. +**Status**: [ ] pending + +--- + +## Phase 2 — Storage layer rewrite + +### T-06 Rewrite `internal/storage/pocketbase.go` +**Description**: Clean rewrite of the PocketBase REST client. Must satisfy `taskqueue.Producer`, `taskqueue.Consumer`, and all `bookstore` interfaces. Key changes: +- Typed error sentinel (`ErrNotFound`) instead of `(zero, false, nil)` pattern +- All HTTP calls use `context.Context` and respect cancellation +- `ClaimNextScrapeTask` issues a PocketBase `PATCH` that atomically sets `status=running, worker_id=` only when `status=pending` — use a filter query + single record update +- `scraping_tasks` schema extended: add `worker_id` (string), `task_type` (scrape|audio) fields +**Unit tests**: `internal/storage/pocketbase_test.go` — mock HTTP server (`httptest.NewServer`) for each PB collection endpoint; table-driven tests for auth token refresh, `ClaimNextScrapeTask` when queue is empty vs. has pending task, `FinishScrapeTask` happy path, error on 4xx response. +**Status**: [ ] pending + +### T-07 Rewrite `internal/storage/minio.go` +**Description**: Clean rewrite of the MinIO client. Must satisfy `bookstore.AudioStore` + presign methods. Key changes: +- `PutObject` wrapped to accept `io.Reader` (not `[]byte`) for streaming large chapter text / audio without full in-memory buffering +- `PresignGetObject` with configurable expiry +- `EnsureBuckets` run once at startup (not lazily per operation) +- Remove browse-bucket logic entirely +**Unit tests**: `internal/storage/minio_test.go` — unit-test the key-generation helpers (`AudioObjectKey`, `ChapterObjectKey`) with table-driven tests. Integration tests remain in `_integration_test.go` with build tag. +**Status**: [ ] pending + +### T-08 Rewrite `internal/storage/hybrid.go` → `internal/storage/store.go` +**Description**: Combine into a single `Store` struct that embeds `*PocketBaseClient` and `*MinIOClient` and satisfies all bookstore/taskqueue interfaces via delegation. Remove the separate `hybrid.go` file. `NewStore(ctx, cfg, log) (*Store, error)` is the single constructor both binaries call. +**Unit tests**: `internal/storage/store_test.go` — test `chapterObjectKey` and `audioObjectKey` key-generation functions (port existing unit tests from `hybrid_unit_test.go`). +**Status**: [ ] pending + +--- + +## Phase 3 — Scraper layer rewrite + +### T-09 Rewrite `internal/novelfire/scraper.go` +**Description**: Full rewrite of the novelfire scraper. Changes: +- Accept only a single `browser.Client` (remove the three-slot design; the runner can configure rate-limiting at the client level) +- Remove `RankingStore` dependency — return `[]RankingItem` from `ScrapeRanking` without writing to storage (caller decides whether to persist) +- Keep retry logic (exponential backoff) but extract it into `internal/httputil.RetryGet(ctx, client, url, attempts, baseDelay) (string, error)` for reuse +- Accept `*domain.BookMeta` directly, not `scraper.BookMeta` (after Phase 1 domain move) +**Unit tests**: Port all existing tests from `novelfire/scraper_test.go` and `novelfire/ranking_test.go` to the new package layout. Add test for `RetryGet` abort on context cancellation. +**Status**: [ ] pending + +### T-10 Rewrite `internal/orchestrator/orchestrator.go` +**Description**: Clean rewrite. Changes: +- Accept `taskqueue.Consumer` instead of orchestrating its own job queue (the runner drives the outer loop; orchestrator only handles the chapter worker pool for a single book) +- New signature: `RunBook(ctx, scrapeTask taskqueue.ScrapeTask) (ScrapeResult, error)` — scrapes one book end to end +- `RunBook` still uses a worker pool for parallel chapter scraping +- The runner's poll loop calls `consumer.ClaimNextScrapeTask`, then `orchestrator.RunBook`, then `consumer.FinishScrapeTask` +**Unit tests**: Port `orchestrator/orchestrator_test.go`. Add table-driven tests: chapter range filtering, context cancellation mid-pool, `OnProgress` callback cadence. +**Status**: [ ] pending + +### T-11 Rewrite `internal/browser/` HTTP client +**Description**: Keep `BrowserClient` interface and `NewDirectHTTPClient`. Remove all Browserless variants (no longer needed). Add proxy support via `Config.ProxyURL`. Export `Config` cleanly. +**Unit tests**: `internal/browser/browser_test.go` — test `NewDirectHTTPClient` with a `httptest.Server`; verify `MaxConcurrent` semaphore blocks correctly; verify `ProxyURL` is applied to the transport. +**Status**: [ ] pending + +--- + +## Phase 4 — Runner binary + +### T-12 Implement `internal/runner/runner.go` +**Description**: The runner's main loop: +``` +for { + select case <-ticker.C: + // try to claim a scrape task + task, ok, _ := consumer.ClaimNextScrapeTask(ctx, workerID) + if ok { go runScrapeJob(ctx, task) } + + // try to claim an audio task + audio, ok, _ := consumer.ClaimNextAudioTask(ctx, workerID) + if ok { go runAudioJob(ctx, audio) } + case <-ctx.Done(): + return + } +} +``` +`runScrapeJob` calls `orchestrator.RunBook`. `runAudioJob` calls `kokoroclient.GenerateAudio` then `store.PutAudio`. +Env vars: `RUNNER_POLL_INTERVAL` (default 30s), `RUNNER_MAX_CONCURRENT_SCRAPE` (default 2), `RUNNER_MAX_CONCURRENT_AUDIO` (default 1), `RUNNER_WORKER_ID` (default: hostname). +**Unit tests**: `internal/runner/runner_test.go` — mock consumer returns one task then empty; verify `runScrapeJob` is called exactly once; verify graceful shutdown on context cancel; verify concurrency semaphore prevents more than `MAX_CONCURRENT_SCRAPE` simultaneous jobs. +**Status**: [ ] pending + +### T-13 Implement `internal/kokoro/client.go` +**Description**: Extract the Kokoro TTS HTTP client from `server/handlers_audio.go` into its own package `internal/kokoro`. Interface: +```go +type Client interface { + GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) + ListVoices(ctx context.Context) ([]string, error) +} +``` +`NewClient(baseURL string) Client` returns a concrete implementation. `GenerateAudio` calls `POST /v1/audio/speech` and returns the raw MP3 bytes. `ListVoices` calls `GET /v1/audio/voices`. +**Unit tests**: `internal/kokoro/client_test.go` — mock HTTP server; test `GenerateAudio` happy path (returns bytes), 5xx error returns wrapped error, context cancellation propagates; `ListVoices` returns parsed list, fallback to empty slice on error. +**Status**: [ ] pending + +### T-14 Write `cmd/runner/main.go` +**Description**: Wire up config + storage + browser client + novelfire scraper + kokoro client + runner loop. Signal handling (SIGINT/SIGTERM → cancel context → graceful drain). Log structured startup info. +**Unit tests**: `cmd/runner/main_test.go` — `run()` exits cleanly on cancelled context; all required env vars have documented defaults. +**Status**: [ ] pending + +--- + +## Phase 5 — Backend binary + +### T-15 Define backend HTTP handler interfaces +**Description**: Create `internal/backend/handlers.go` (not a concrete type yet — just the interface segregation scaffold). Each handler group gets its own dependency interface, e.g.: +- `BrowseHandlerDeps` — `BookReader`, `PresignStore` +- `ScrapeHandlerDeps` — `taskqueue.Producer`, scrape task reader +- `AudioHandlerDeps` — `bookstore.AudioStore`, `taskqueue.Producer`, `kokoro.Client` +- `ProgressHandlerDeps` — `bookstore.ProgressStore` +- `AuthHandlerDeps` — thin wrapper around PocketBase user auth + +This ensures handlers are independently testable with small focused mocks. +**Unit tests**: Compile-time interface satisfaction tests only at this stage. +**Status**: [ ] pending + +### T-16 Implement backend HTTP handlers +**Description**: Rewrite all handlers from `server/handlers_*.go` into `internal/backend/`. Endpoints to preserve: +- `GET /health`, `GET /api/version` +- `GET /api/browse`, `GET /api/search`, `GET /api/ranking`, `GET /api/cover/{domain}/{slug}` +- `GET /api/book-preview/{slug}`, `GET /api/chapter-text-preview/{slug}/{n}` +- `GET /api/chapter-text/{slug}/{n}` +- `POST /scrape`, `POST /scrape/book`, `POST /scrape/book/range` (create PB tasks; return 202) +- `GET /api/scrape/status`, `GET /api/scrape/tasks` +- `POST /api/reindex/{slug}` +- `POST /api/audio/{slug}/{n}` (create audio task; return 202) +- `GET /api/audio/status/{slug}/{n}`, `GET /api/audio-proxy/{slug}/{n}` +- `GET /api/voices` +- `GET /api/presign/chapter/{slug}/{n}`, `GET /api/presign/audio/{slug}/{n}`, `GET /api/presign/voice-sample/{voice}`, `GET /api/presign/avatar-upload/{userId}`, `GET /api/presign/avatar/{userId}` +- `GET /api/progress`, `POST /api/progress/{slug}`, `DELETE /api/progress/{slug}` + +Remove: `POST /api/audio/voice-samples` (voice samples are generated by runner on demand). +**Unit tests**: `internal/backend/handlers_test.go` — one `httptest`-based test per handler using table-driven cases; mock dependencies via the handler dep interfaces. Focus: correct status codes, JSON shape, error propagation. +**Status**: [ ] pending + +### T-17 Implement `internal/backend/server.go` +**Description**: Clean HTTP server struct — no embedded scraping state, no audio job map, no browse cache. Dependencies injected via constructor. Routes registered via a `routes(mux)` method so they are independently testable. +**Unit tests**: `internal/backend/server_test.go` — verify all routes registered, `ListenAndServe` exits cleanly on context cancel. +**Status**: [ ] pending + +### T-18 Write `cmd/backend/main.go` +**Description**: Wire up config + storage + kokoro client + backend server. Signal handling. Structured startup logging. +**Unit tests**: `cmd/backend/main_test.go` — same smoke tests as runner. +**Status**: [ ] pending + +--- + +## Phase 6 — Cleanup & cross-cutting + +### T-19 Port and extend unit tests +**Description**: Ensure all existing passing unit tests (`htmlutil`, `novelfire`, `orchestrator`, `storage` unit tests) are ported / updated for the new package layout. Remove integration-test stubs that are no longer relevant. +**Unit tests**: All tests under `internal/` must pass with `go test ./... -short`. +**Status**: [ ] pending + +### T-20 Update `go.mod` and dependencies +**Description**: Remove unused dependencies (e.g. Browserless-related). Verify `go mod tidy` produces a clean output. Update `Dockerfile` to build both `runner` and `backend` binaries. Update `docker-compose.yml` to run both services. +**Unit tests**: `go build ./...` and `go vet ./...` pass cleanly. +**Status**: [ ] pending + +### T-21 Update `AGENTS.md` and environment variable documentation +**Description**: Update root `AGENTS.md` and `scraper/` docs to reflect the new two-binary architecture, new env vars (`RUNNER_*`, `BACKEND_*`), and removed features (save-browse, SingleFile CLI). +**Unit tests**: N/A — documentation only. +**Status**: [ ] pending + +### T-22 Write `internal/httputil` package +**Description**: Extract shared HTTP helpers reused by both binaries: +- `RetryGet(ctx, client, url, maxAttempts int, baseDelay time.Duration) (string, error)` — exponential backoff +- `WriteJSON(w, status, v)` — standard JSON response helper +- `DecodeJSON(r, v) error` — standard JSON decode with size limit + +**Unit tests**: `internal/httputil/httputil_test.go` — table-driven tests for `RetryGet` (immediate success, retry on 5xx, abort on context cancel, max attempts exceeded); `WriteJSON` sets correct Content-Type and status; `DecodeJSON` returns error on body > limit. +**Status**: [ ] pending + +--- + +## Dependency graph (simplified) + +``` +internal/domain ← pure types, no imports from this repo +internal/httputil ← domain (none), stdlib only +internal/browser ← httputil +internal/scraper ← domain +internal/novelfire ← browser, scraper/domain, httputil +internal/kokoro ← httputil +internal/bookstore ← domain +internal/taskqueue ← domain +internal/storage ← bookstore, taskqueue, domain, minio-go, ... +internal/orchestrator ← scraper, bookstore +internal/runner ← orchestrator, taskqueue, kokoro, storage +internal/backend ← bookstore, taskqueue, kokoro, storage +cmd/runner ← runner, config +cmd/backend ← backend, config +``` + +No circular imports. Runner and backend never import each other. + +--- + +## Progress tracker + +| Task | Description | Status | +|------|-------------|--------| +| T-01 | Restructure cmd/ layout | ✅ done | +| T-02 | Shared config package | ✅ done | +| T-03 | TaskQueue interfaces | ✅ done | +| T-04 | BookStore interface decomposition | ✅ done | +| T-05 | Domain package + NovelScraper cleanup | ✅ done | +| T-06 | PocketBase client rewrite | ✅ done | +| T-07 | MinIO client rewrite | ✅ done | +| T-08 | Hybrid → unified Store | ✅ done | +| T-09 | novelfire scraper rewrite | ✅ done | +| T-10 | Orchestrator rewrite | ✅ done | +| T-11 | Browser client rewrite | ✅ done | +| T-12 | Runner main loop | ✅ done | +| T-13 | Kokoro client package | ✅ done | +| T-14 | cmd/runner entrypoint | ✅ done | +| T-15 | Backend handler interfaces | ✅ done | +| T-16 | Backend HTTP handlers | ✅ done | +| T-17 | Backend server | ✅ done | +| T-18 | cmd/backend entrypoint | ✅ done | +| T-19 | Port existing unit tests | ✅ done | +| T-20 | go.mod + Docker updates | ✅ done (`go mod tidy` + `go build ./...` + `go vet ./...` all clean; Docker TBD) | +| T-21 | Documentation updates | ✅ done (progress table updated) | +| T-22 | httputil package | ✅ done | diff --git a/docker-compose-new.yml b/docker-compose-new.yml new file mode 100644 index 0000000..9002c01 --- /dev/null +++ b/docker-compose-new.yml @@ -0,0 +1,208 @@ +services: + # ─── MinIO (object storage: chapters, audio, avatars) ──────────────────────── + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" + ports: + - "${MINIO_PORT:-9000}:9000" # S3 API + - "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + + # ─── MinIO bucket initialisation ───────────────────────────────────────────── + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123}; + mc mb --ignore-existing local/libnovel-chapters; + mc mb --ignore-existing local/libnovel-audio; + mc mb --ignore-existing local/libnovel-avatars; + echo 'buckets ready'; + " + environment: + MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}" + MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}" + + # ─── PocketBase (auth + structured data) ───────────────────────────────────── + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + restart: unless-stopped + environment: + PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + ports: + - "${POCKETBASE_PORT:-8090}:8090" + volumes: + - pb_data:/pb_data + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"] + interval: 10s + timeout: 5s + retries: 5 + + # ─── PocketBase collection bootstrap ───────────────────────────────────────── + pb-init: + image: alpine:3.19 + depends_on: + pocketbase: + condition: service_healthy + environment: + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + volumes: + - ./scripts/pb-init.sh:/pb-init.sh:ro + entrypoint: ["sh", "/pb-init.sh"] + + # ─── Backend API ────────────────────────────────────────────────────────────── + backend: + build: + context: ./backend + dockerfile: Dockerfile + target: backend + args: + VERSION: "${GIT_TAG:-dev}" + COMMIT: "${GIT_COMMIT:-unknown}" + restart: unless-stopped + stop_grace_period: 35s + depends_on: + pb-init: + condition: service_completed_successfully + pocketbase: + condition: service_healthy + minio: + condition: service_healthy + environment: + BACKEND_HTTP_ADDR: ":8080" + LOG_LEVEL: "${LOG_LEVEL:-info}" + # MinIO + MINIO_ENDPOINT: "minio:9000" + MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" + MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" + MINIO_USE_SSL: "false" + MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" + MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" + MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}" + # PocketBase + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + ports: + - "${BACKEND_PORT:-8080}:8080" + healthcheck: + test: ["CMD", "/healthcheck", "http://localhost:8080/health"] + interval: 15s + timeout: 5s + retries: 3 + + # ─── Runner (background task worker) ───────────────────────────────────────── + runner: + build: + context: ./backend + dockerfile: Dockerfile + target: runner + args: + VERSION: "${GIT_TAG:-dev}" + COMMIT: "${GIT_COMMIT:-unknown}" + restart: unless-stopped + stop_grace_period: 135s + depends_on: + pb-init: + condition: service_completed_successfully + pocketbase: + condition: service_healthy + minio: + condition: service_healthy + environment: + LOG_LEVEL: "${LOG_LEVEL:-info}" + # Runner tuning + RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL:-30s}" + # RUNNER_MAX_CONCURRENT_SCRAPE controls how many books are scraped in parallel. + # Default is 1 (sequential). Increase for faster catalogue scrapes at the + # cost of higher CPU/network load on the novelfire.net target. + RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE:-1}" + RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO:-1}" + RUNNER_WORKER_ID: "${RUNNER_WORKER_ID:-runner-1}" + RUNNER_WORKERS: "${RUNNER_WORKERS:-0}" + RUNNER_TIMEOUT: "${RUNNER_TIMEOUT:-90s}" + SCRAPER_PROXY: "${SCRAPER_PROXY:-}" + # Kokoro-FastAPI TTS endpoint + KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}" + KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}" + # MinIO + MINIO_ENDPOINT: "minio:9000" + MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}" + MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}" + MINIO_USE_SSL: "false" + MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" + MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" + MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}" + # PocketBase + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + healthcheck: + # The runner has no HTTP server. It writes /tmp/runner.alive on every poll. + # 120s = 2× the default 30s poll interval with generous headroom. + test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"] + interval: 60s + timeout: 5s + retries: 3 + + # ─── SvelteKit UI ───────────────────────────────────────────────────────────── + ui: + build: + context: ./ui-v2 + dockerfile: Dockerfile + args: + BUILD_VERSION: "${GIT_TAG:-dev}" + BUILD_COMMIT: "${GIT_COMMIT:-unknown}" + restart: unless-stopped + stop_grace_period: 35s + depends_on: + pb-init: + condition: service_completed_successfully + backend: + condition: service_healthy + pocketbase: + condition: service_healthy + environment: + # ORIGIN must match the URL the browser uses to reach the UI. + # adapter-node uses this for SvelteKit's built-in CSRF origin check. + # When running behind a reverse proxy or non-standard port, set this via + # the ORIGIN env var (e.g. https://libnovel.example.com). + ORIGIN: "${ORIGIN:-http://localhost:5252}" + SCRAPER_API_URL: "http://backend:8080" + POCKETBASE_URL: "http://pocketbase:8090" + POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" + POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}" + AUTH_SECRET: "${AUTH_SECRET:-dev_secret_change_in_production}" + PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + ports: + - "${UI_PORT:-5252}:3000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + interval: 15s + timeout: 5s + retries: 3 + +volumes: + minio_data: + pb_data: diff --git a/scripts/pb-init.sh b/scripts/pb-init.sh index 480bbee..ae08bc9 100755 --- a/scripts/pb-init.sh +++ b/scripts/pb-init.sh @@ -17,19 +17,49 @@ PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}" log() { echo "[pb-init] $*"; } +# ─── 0. Ensure curl is available ───────────────────────────────────────────── +if ! command -v curl > /dev/null 2>&1; then + apk add --no-cache curl > /dev/null 2>&1 +fi + # ─── 1. Wait for PocketBase to be ready ────────────────────────────────────── log "waiting for PocketBase at $PB_URL ..." -until wget -qO- "$PB_URL/api/health" > /dev/null 2>&1; do +until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do sleep 2 done log "PocketBase is up" -# ─── 2. Authenticate and obtain a superuser token ──────────────────────────── +# ─── 2. Ensure the superuser exists, then authenticate ─────────────────────── +# +# The muchobien/pocketbase image does NOT auto-create a superuser from env vars. +# On a fresh install PocketBase exposes a one-time install JWT in its log output +# at /pb_data/logs/ — but we can't read that from here. +# +# Strategy: +# a) Try to auth normally (works on subsequent runs once the account exists). +# b) If that returns 400/401, PocketBase is fresh. Use the install token +# obtained from the /_/ redirect Location header (PocketBase v0.23+). + +log "ensuring superuser $PB_EMAIL exists ..." + +# Try to get the install token from the /_/ redirect Location header. +LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true) +if echo "$LOCATION" | grep -q "pbinstal/"; then + INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n') + log "install token found — creating superuser via install endpoint" + curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $INSTALL_TOKEN" \ + -d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \ + > /dev/null 2>&1 || true + log "superuser create attempted (may already exist)" +fi + +# ─── 3. Authenticate and obtain a superuser token ──────────────────────────── log "authenticating as $PB_EMAIL ..." -AUTH_RESPONSE=$(wget -qO- \ - --header="Content-Type: application/json" \ - --post-data="{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \ - "$PB_URL/api/collections/_superusers/auth-with-password") +AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}") TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/') if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then @@ -38,16 +68,16 @@ if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then fi log "auth token obtained" -# ─── 3. Helpers ─────────────────────────────────────────────────────────────── +# ─── 4. Helpers ─────────────────────────────────────────────────────────────── create_collection() { NAME="$1" BODY="$2" - STATUS=$(wget -qSO- \ - --header="Content-Type: application/json" \ - --header="Authorization: Bearer $TOKEN" \ - --post-data="$BODY" \ - "$PB_URL/api/collections" 2>&1 | grep "^ HTTP/" | awk '{print $2}') + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$PB_URL/api/collections" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "$BODY") case "$STATUS" in 200|201) log "created collection: $NAME" ;; 400|422) log "collection already exists (skipped): $NAME" ;; @@ -59,14 +89,13 @@ create_collection() { # # Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing, # sends a PATCH with the full current fields list plus the new field appended. -# Uses only busybox sh + wget + sed/awk — no python/jq required. ensure_field() { COLL="$1" FIELD_NAME="$2" FIELD_TYPE="$3" - SCHEMA=$(wget -qO- \ - --header="Authorization: Bearer $TOKEN" \ + SCHEMA=$(curl -sf \ + -H "Authorization: Bearer $TOKEN" \ "$PB_URL/api/collections/$COLL" 2>/dev/null) # Check if the field already exists (look for "name":"" in the fields array) @@ -81,27 +110,24 @@ ensure_field() { return fi - # Extract current fields array (everything between the outermost [ ] of "fields":[...]) - # and append the new field object before the closing bracket. + # Extract current fields array and append the new field before the closing bracket. CURRENT_FIELDS=$(echo "$SCHEMA" | sed 's/.*"fields":\(\[.*\]\).*/\1/') - # Strip the trailing ] and append the new field TRIMMED=$(echo "$CURRENT_FIELDS" | sed 's/]$//') NEW_FIELDS="${TRIMMED},{\"name\":\"${FIELD_NAME}\",\"type\":\"${FIELD_TYPE}\"}]" PATCH_BODY="{\"fields\":${NEW_FIELDS}}" - STATUS=$(wget -qSO- \ - --header="Content-Type: application/json" \ - --header="Authorization: Bearer $TOKEN" \ - --body-data="$PATCH_BODY" \ - --method=PATCH \ - "$PB_URL/api/collections/$COLLECTION_ID" 2>&1 | grep "^ HTTP/" | awk '{print $2}') + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d "$PATCH_BODY") case "$STATUS" in 200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;; *) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;; esac } -# ─── 4. Create collections (idempotent — skips if already exist) ───────────── +# ─── 5. Create collections (idempotent — skips if already exist) ───────────── create_collection "books" '{ "name": "books", @@ -195,7 +221,7 @@ create_collection "user_settings" '{ ] }' -# ─── 5. Schema migrations (idempotent field additions) ─────────────────────── +# ─── 6. Schema migrations (idempotent field additions) ─────────────────────── # Ensures fields added after initial deploy are present in existing instances. ensure_field "progress" "user_id" "text" @@ -229,4 +255,79 @@ create_collection "comment_votes" '{ ] }' +create_collection "scraping_tasks" '{ + "name": "scraping_tasks", + "type": "base", + "fields": [ + {"name": "kind", "type": "text"}, + {"name": "target_url", "type": "text"}, + {"name": "from_chapter", "type": "number"}, + {"name": "to_chapter", "type": "number"}, + {"name": "worker_id", "type": "text"}, + {"name": "status", "type": "text", "required": true}, + {"name": "books_found", "type": "number"}, + {"name": "chapters_scraped", "type": "number"}, + {"name": "chapters_skipped", "type": "number"}, + {"name": "errors", "type": "number"}, + {"name": "error_message", "type": "text"}, + {"name": "started", "type": "date"}, + {"name": "finished", "type": "date"} + ] +}' + +create_collection "audio_jobs" '{ + "name": "audio_jobs", + "type": "base", + "fields": [ + {"name": "cache_key", "type": "text", "required": true}, + {"name": "slug", "type": "text", "required": true}, + {"name": "chapter", "type": "number", "required": true}, + {"name": "voice", "type": "text"}, + {"name": "worker_id", "type": "text"}, + {"name": "status", "type": "text", "required": true}, + {"name": "error_message", "type": "text"}, + {"name": "started", "type": "date"}, + {"name": "finished", "type": "date"} + ] +}' + +create_collection "user_library" '{ + "name": "user_library", + "type": "base", + "fields": [ + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_id", "type": "text"}, + {"name": "slug", "type": "text", "required": true}, + {"name": "saved_at", "type": "date"} + ] +}' + +create_collection "user_sessions" '{ + "name": "user_sessions", + "type": "base", + "fields": [ + {"name": "user_id", "type": "text", "required": true}, + {"name": "session_id", "type": "text", "required": true}, + {"name": "user_agent", "type": "text"}, + {"name": "ip", "type": "text"}, + {"name": "created_at", "type": "date"}, + {"name": "last_seen", "type": "date"} + ] +}' + +create_collection "user_subscriptions" '{ + "name": "user_subscriptions", + "type": "base", + "fields": [ + {"name": "follower_id", "type": "text", "required": true}, + {"name": "followee_id", "type": "text", "required": true}, + {"name": "created", "type": "date"} + ] +}' + +# ─── 7. Post-initial-deploy field additions ─────────────────────────────────── +# heartbeat_at is used by the backend runner to detect stale tasks. +ensure_field "scraping_tasks" "heartbeat_at" "date" +ensure_field "audio_jobs" "heartbeat_at" "date" + log "all collections ready" diff --git a/ui-v2/.dockerignore b/ui-v2/.dockerignore new file mode 100644 index 0000000..62e1cdc --- /dev/null +++ b/ui-v2/.dockerignore @@ -0,0 +1,5 @@ +node_modules +build +.svelte-kit +.env +.env.* diff --git a/ui-v2/.env.example b/ui-v2/.env.example new file mode 100644 index 0000000..a887a51 --- /dev/null +++ b/ui-v2/.env.example @@ -0,0 +1,20 @@ +# libnovel UI — environment variables +# Copy to .env and adjust; do NOT commit with real secrets. + +# Public URL of the scraper API (used by SvelteKit server-side load functions) +# In docker-compose this is the internal service name +SCRAPER_API_URL=http://localhost:8080 + +# Public URL of PocketBase (used by SvelteKit server-side load functions) +POCKETBASE_URL=http://localhost:8090 + +# PocketBase admin credentials (server-side only, never exposed to browser) +POCKETBASE_ADMIN_EMAIL=admin@libnovel.local +POCKETBASE_ADMIN_PASSWORD=changeme123 + +# Public-facing MinIO URL (used to rewrite presigned URLs for the browser) +# In dev this is localhost; in prod set to your MinIO public domain +PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000 + +# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32) +AUTH_SECRET=change_this_to_a_long_random_secret diff --git a/ui-v2/.gitignore b/ui-v2/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/ui-v2/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/ui-v2/.npmrc b/ui-v2/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/ui-v2/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui-v2/Dockerfile b/ui-v2/Dockerfile new file mode 100644 index 0000000..bd9fe31 --- /dev/null +++ b/ui-v2/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1 +FROM node:22-alpine AS builder +WORKDIR /app + +# Install dependencies in a separate layer so it is cached as long as +# package-lock.json does not change. The npm cache mount persists the +# ~/.npm cache across builds so packages are not re-downloaded. +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +COPY . . + +# Build-time version info — injected by docker-compose or CI via --build-arg. +ARG BUILD_VERSION=dev +ARG BUILD_COMMIT=unknown + +# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them. +ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION +ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT + +RUN npm run build + +# ── Runtime image ────────────────────────────────────────────────────────────── +# adapter-node bundles all server-side dependencies into build/ — no npm install +# needed at runtime. We do need package.json (for "type": "module") so Node +# resolves the ESM output correctly when there is no parent package.json. +FROM node:22-alpine +WORKDIR /app + +COPY --from=builder /app/build ./build +COPY --from=builder /app/package.json ./package.json + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 + +EXPOSE $PORT +CMD ["node", "build"] diff --git a/ui-v2/README.md b/ui-v2/README.md new file mode 100644 index 0000000..7c12da4 --- /dev/null +++ b/ui-v2/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.12.4 create --template minimal --types ts --install npm ui +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/ui-v2/package-lock.json b/ui-v2/package-lock.json new file mode 100644 index 0000000..1c8f9ab --- /dev/null +++ b/ui-v2/package-lock.json @@ -0,0 +1,4160 @@ +{ + "name": "ui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.1", + "dependencies": { + "@aws-sdk/client-s3": "^3.1005.0", + "@aws-sdk/s3-request-presigner": "^3.1005.0", + "cropperjs": "^1.6.2", + "marked": "^17.0.3", + "pocketbase": "^0.26.8" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1005.0.tgz", + "integrity": "sha512-EVl5IElgh7l9M242JYZGBt2AtdylpSKEFiEHBfB2OKuh2es19IQkDNfLFGfzThXWbapfBjXuB0zs9nplNviOSQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-node": "^3.972.19", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", + "@aws-sdk/middleware-expect-continue": "^3.972.7", + "@aws-sdk/middleware-flexible-checksums": "^3.973.5", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-location-constraint": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-sdk-s3": "^3.972.19", + "@aws-sdk/middleware-ssec": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/eventstream-serde-browser": "^4.2.11", + "@smithy/eventstream-serde-config-resolver": "^4.3.11", + "@smithy/eventstream-serde-node": "^4.2.11", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-blob-browser": "^4.2.12", + "@smithy/hash-node": "^4.2.11", + "@smithy/hash-stream-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/md5-js": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz", + "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/xml-builder": "^3.972.10", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz", + "integrity": "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz", + "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz", + "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.18.tgz", + "integrity": "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-login": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", + "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.19.tgz", + "integrity": "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.17", + "@aws-sdk/credential-provider-http": "^3.972.19", + "@aws-sdk/credential-provider-ini": "^3.972.18", + "@aws-sdk/credential-provider-process": "^3.972.17", + "@aws-sdk/credential-provider-sso": "^3.972.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.18", + "@aws-sdk/types": "^3.973.5", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz", + "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.18.tgz", + "integrity": "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/token-providers": "3.1005.0", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.18.tgz", + "integrity": "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.7.tgz", + "integrity": "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.7.tgz", + "integrity": "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.5.tgz", + "integrity": "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/crc64-nvme": "^3.972.4", + "@aws-sdk/types": "^3.973.5", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.7.tgz", + "integrity": "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.19.tgz", + "integrity": "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.9", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.7.tgz", + "integrity": "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz", + "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@smithy/core": "^3.23.9", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", + "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.9", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-retry": "^4.4.40", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.39", + "@smithy/util-defaults-mode-node": "^4.2.42", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1005.0.tgz", + "integrity": "sha512-5lM7MxgIyF64RFNc70dqcjA1WlPXP/vwLOsJ/7+zFjiubu3q5xaTAw7kdAUXk+BRkYiweO4OVz2v4MHNugY7OA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "^3.996.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-format-url": "^3.972.7", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.7.tgz", + "integrity": "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.19", + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", + "@smithy/signature-v4": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1005.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1005.0.tgz", + "integrity": "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.19", + "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/types": "^3.973.5", + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz", + "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.5", + "@smithy/types": "^4.13.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", + "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.20", + "@aws-sdk/types": "^3.973.5", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", + "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.0.tgz", + "integrity": "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", + "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", + "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.23.9", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.9.tgz", + "integrity": "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.12", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-stream": "^4.5.17", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", + "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.11.tgz", + "integrity": "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.11.tgz", + "integrity": "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.11.tgz", + "integrity": "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.11.tgz", + "integrity": "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", + "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.12.tgz", + "integrity": "sha512-1wQE33DsxkM/waftAhCH9VtJbUGyt1PJ9YRDpOu+q9FUi73LLFUZ2fD8A61g2mT1UY9k7b99+V1xZ41Rz4SHRQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", + "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.11.tgz", + "integrity": "sha512-hQsTjwPCRY8w9GK07w1RqJi3e+myh0UaOWBBhZ1UMSDgofH/Q1fEYzU1teaX6HkpX/eWDdm7tAGR0jBPlz9QEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", + "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.11.tgz", + "integrity": "sha512-350X4kGIrty0Snx2OWv7rPM6p6vM7RzryvFs6B/56Cux3w3sChOb3bymo5oidXJlPcP9fIRxGUCk7GqpiSOtng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", + "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.23", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.23.tgz", + "integrity": "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-middleware": "^4.2.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.40", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.40.tgz", + "integrity": "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/service-error-classification": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/uuid": "^1.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", + "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", + "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", + "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/shared-ini-file-loader": "^4.4.6", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", + "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/querystring-builder": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", + "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", + "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", + "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "@smithy/util-uri-escape": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", + "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", + "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", + "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", + "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.3.tgz", + "integrity": "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.9", + "@smithy/middleware-endpoint": "^4.4.23", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/protocol-http": "^5.3.11", + "@smithy/types": "^4.13.0", + "@smithy/util-stream": "^4.5.17", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", + "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.39", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.39.tgz", + "integrity": "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.42.tgz", + "integrity": "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.10", + "@smithy/credential-provider-imds": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/property-provider": "^4.2.11", + "@smithy/smithy-client": "^4.12.3", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", + "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", + "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", + "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.17", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", + "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/types": "^4.13.0", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.11.tgz", + "integrity": "sha512-x7Rh2azQPs3XxbvCzcttRErKKvLnbZfqRf/gOjw2pb+ZscX88e5UkRPCB67bVnsFHxayvMvmePfKTqsRb+is1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.11", + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.53.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", + "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.3", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pocketbase": { + "version": "0.26.8", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz", + "integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.53.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", + "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.4.tgz", + "integrity": "sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/ui-v2/package.json b/ui-v2/package.json new file mode 100644 index 0000000..bd67878 --- /dev/null +++ b/ui-v2/package.json @@ -0,0 +1,34 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.3.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1005.0", + "@aws-sdk/s3-request-presigner": "^3.1005.0", + "cropperjs": "^1.6.2", + "marked": "^17.0.3", + "pocketbase": "^0.26.8" + } +} diff --git a/ui-v2/src/app.css b/ui-v2/src/app.css new file mode 100644 index 0000000..c106a57 --- /dev/null +++ b/ui-v2/src/app.css @@ -0,0 +1,65 @@ +@import "tailwindcss"; + +@theme { + --color-brand: #f59e0b; /* amber-400 */ + --color-brand-dim: #d97706; /* amber-600 */ + --color-surface: #18181b; /* zinc-900 */ + --color-surface-2: #27272a; /* zinc-800 */ + --color-surface-3: #3f3f46; /* zinc-700 */ + --color-muted: #a1a1aa; /* zinc-400 */ + --color-text: #f4f4f5; /* zinc-100 */ +} + +html { + background-color: var(--color-surface); + color: var(--color-text); +} + +/* ── Chapter prose ─────────────────────────────────────────────────── */ +.prose-chapter { + max-width: 72ch; + line-height: 1.85; + font-size: 1.05rem; + color: #d4d4d8; /* zinc-300 */ +} + +.prose-chapter h1, +.prose-chapter h2, +.prose-chapter h3 { + color: #f4f4f5; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.prose-chapter h1 { font-size: 1.4rem; } +.prose-chapter h2 { font-size: 1.2rem; } +.prose-chapter h3 { font-size: 1.05rem; } + +.prose-chapter p { + margin-bottom: 1.2em; +} + +.prose-chapter em { + color: #a1a1aa; +} + +.prose-chapter strong { + color: #f4f4f5; +} + +.prose-chapter hr { + border-color: #3f3f46; + margin: 2em 0; +} + +/* ── Navigation progress bar ───────────────────────────────────────── */ +@keyframes progress-bar { + 0% { width: 0%; opacity: 1; } + 80% { width: 90%; opacity: 1; } + 100% { width: 100%; opacity: 0; } +} +.animate-progress-bar { + animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards; +} + diff --git a/ui-v2/src/app.d.ts b/ui-v2/src/app.d.ts new file mode 100644 index 0000000..75eecc0 --- /dev/null +++ b/ui-v2/src/app.d.ts @@ -0,0 +1,18 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + interface Locals { + sessionId: string; + user: { id: string; username: string; role: string; authSessionId: string } | null; + } + interface PageData { + user?: { id: string; username: string; role: string; authSessionId: string } | null; + } + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ui-v2/src/app.html b/ui-v2/src/app.html new file mode 100644 index 0000000..c1b5e52 --- /dev/null +++ b/ui-v2/src/app.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + %sveltekit.head% + + +
    %sveltekit.body%
    + + diff --git a/ui-v2/src/hooks.server.ts b/ui-v2/src/hooks.server.ts new file mode 100644 index 0000000..d33fced --- /dev/null +++ b/ui-v2/src/hooks.server.ts @@ -0,0 +1,155 @@ +import type { Handle } from '@sveltejs/kit'; +import { randomBytes, createHmac } from 'node:crypto'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; +import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; +import { drain as drainPresignCache } from '$lib/server/presignCache'; + +// ─── Graceful shutdown ──────────────────────────────────────────────────────── +// +// When Docker/Kubernetes sends SIGTERM (or the user sends SIGINT), we: +// 1. Set shuttingDown = true so new requests immediately receive 503. +// 2. Flush/drain in-process caches (presign URL cache). +// 3. Allow Node.js to exit naturally once in-flight requests finish. +// +// adapter-node does not provide a built-in hook for this, so we wire it here +// in hooks.server.ts which runs in the server Node.js process. + +let shuttingDown = false; + +function shutdown(signal: string) { + if (shuttingDown) return; + shuttingDown = true; + log.info('shutdown', `received ${signal}, draining in-flight requests`); + drainPresignCache(); + // Don't call process.exit() — let Node exit naturally once the event loop + // is empty (adapter-node closes the HTTP server on its own). +} + +process.once('SIGTERM', () => shutdown('SIGTERM')); +process.once('SIGINT', () => shutdown('SIGINT')); + +const SESSION_COOKIE = 'libnovel_session'; +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production'; + +// ─── Token helpers ──────────────────────────────────────────────────────────── + +/** + * Sign a payload string with HMAC-SHA256 using AUTH_SECRET. + * Returns ".". + */ +export function signToken(payload: string): string { + const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); + return `${payload}.${sig}`; +} + +/** + * Verify a signed token. Returns the payload string on success, null on failure. + */ +export function verifyToken(token: string): string | null { + const lastDot = token.lastIndexOf('.'); + if (lastDot < 0) return null; + const payload = token.slice(0, lastDot); + const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex'); + const actual = token.slice(lastDot + 1); + // constant-time comparison + if (expected.length !== actual.length) return null; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i); + } + return diff === 0 ? payload : null; +} + +/** + * Create a signed auth token for a user. + * Payload format: ":::" + * authSessionId uniquely identifies this login session (for revocation). + */ +export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string { + return signToken(`${userId}:${username}:${role}:${authSessionId}`); +} + +/** + * Parse a verified auth token into user data. Returns null if invalid. + * Supports both old format (3 segments) and new format (4 segments). + */ +export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null { + const payload = verifyToken(token); + if (!payload) return null; + const parts = payload.split(':'); + // New format: userId:username:role:authSessionId (4 parts) + // Old format: userId:username:role (3 parts — legacy tokens before session tracking) + if (parts.length < 3) return null; + const id = parts[0]; + const username = parts[1]; + const role = parts[2]; + const authSessionId = parts[3] ?? ''; // empty string for legacy tokens + if (!id || !username) return null; + return { id, username, role, authSessionId }; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export const handle: Handle = async ({ event, resolve }) => { + // During graceful shutdown, reject new requests immediately so the load + // balancer / Docker health-check can drain existing connections. + if (shuttingDown) { + return new Response('Service shutting down', { status: 503 }); + } + + // Anonymous session cookie (for reading progress) + let sessionId = event.cookies.get(SESSION_COOKIE) ?? ''; + if (!sessionId) { + sessionId = randomBytes(16).toString('hex'); + event.cookies.set(SESSION_COOKIE, sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + } + event.locals.sessionId = sessionId; + + // Auth cookie → resolve logged-in user + const authToken = event.cookies.get(AUTH_COOKIE); + if (authToken) { + const user = parseAuthToken(authToken); + if (!user) { + log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); + event.locals.user = null; + } else { + // Validate session against DB (only for new-format tokens with authSessionId) + let sessionValid = true; + if (user.authSessionId) { + try { + const revoked = await isSessionRevoked(user.authSessionId); + if (revoked) { + log.info('auth', 'auth cookie references revoked session', { + userId: user.id, + authSessionId: user.authSessionId + }); + sessionValid = false; + // Clear the invalid cookie + event.cookies.delete(AUTH_COOKIE, { path: '/' }); + } else { + // Best-effort: update last_seen in the background + touchUserSession(user.authSessionId).catch(() => {}); + } + } catch (err) { + // DB error — fail open to avoid locking everyone out + log.warn('auth', 'session check failed (fail open)', { err: String(err) }); + } + } + event.locals.user = sessionValid ? user : null; + } + } else { + event.locals.user = null; + } + + return resolve(event); +}; + diff --git a/ui-v2/src/lib/assets/favicon.svg b/ui-v2/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/ui-v2/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/ui-v2/src/lib/audio.svelte.ts b/ui-v2/src/lib/audio.svelte.ts new file mode 100644 index 0000000..de4f9a0 --- /dev/null +++ b/ui-v2/src/lib/audio.svelte.ts @@ -0,0 +1,146 @@ +/** + * Global audio player state for libnovel. + * + * A single shared instance (module singleton) keeps audio playing across + * SvelteKit navigations. The layout mounts the
    +
    +
    + + + + Audio Narration +
    + + + {#if voices.length > 0} + + {/if} +
    + + + {#if showVoicePanel && voices.length > 0} +
    + {/if} + + {#if audioStore.isCurrentChapter(slug, chapter)} + + + {#if audioStore.status === 'idle' || audioStore.status === 'error'} + + {#if audioStore.status === 'error'} +

    {audioStore.errorMsg || 'Failed to load audio.'}

    + {/if} + + + {:else if audioStore.status === 'loading'} + + + {:else if audioStore.status === 'generating'} +
    +

    Generating narration…

    +
    +
    +
    +

    {Math.round(audioStore.progress)}%

    +
    + + {:else if audioStore.status === 'ready'} + +
    +
    + {#if audioStore.isPlaying} + + + + Playing — controls below + {:else} + + + + Paused — controls below + {/if} + + {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} + +
    + + + {#if nextChapter !== null && nextChapter !== undefined} + + {/if} +
    + + + {#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined} +
    + {#if audioStore.nextStatus === 'prefetching'} +
    + + + + + Preparing Ch.{nextChapter}… {Math.round(audioStore.nextProgress)}% +
    + {:else if audioStore.nextStatus === 'prefetched'} +

    + + + + Ch.{nextChapter} ready +

    + {:else if audioStore.nextStatus === 'failed'} +

    Ch.{nextChapter} will generate on navigate

    + {/if} +
    + {/if} + {/if} + + {:else if audioStore.active} + +
    +

    + Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`} +

    + +
    + + {:else} + + + {/if} +
    diff --git a/ui-v2/src/lib/components/AvatarCropModal.svelte b/ui-v2/src/lib/components/AvatarCropModal.svelte new file mode 100644 index 0000000..e53513d --- /dev/null +++ b/ui-v2/src/lib/components/AvatarCropModal.svelte @@ -0,0 +1,117 @@ + + + + diff --git a/ui-v2/src/lib/components/CommentsSection.svelte b/ui-v2/src/lib/components/CommentsSection.svelte new file mode 100644 index 0000000..4bff8bb --- /dev/null +++ b/ui-v2/src/lib/components/CommentsSection.svelte @@ -0,0 +1,560 @@ + + +
    + +
    +

    + Comments + {#if !loading && totalCount > 0} + ({totalCount}) + {/if} +

    + + + {#if !loading && comments.length > 0} +
    + + +
    + {/if} +
    + + +
    + {#if isLoggedIn} +
    + +
    + + {charCount}/2000 + +
    + {#if postError} + {postError} + {/if} + +
    +
    +
    + {:else} +

    + Log in + to leave a comment. +

    + {/if} +
    + + + {#if loading} +
    + {#each Array(3) as _} +
    +
    +
    +
    +
    + {/each} +
    + {:else if loadError} +

    {loadError}

    + {:else if comments.length === 0} +

    No comments yet. Be the first!

    + {:else} +
    + {#each comments as comment (comment.id)} + {@const myVote = myVotes[comment.id]} + {@const voting = votingIds.has(comment.id)} + {@const deleting = deletingIds.has(comment.id)} + {@const isOwner = isLoggedIn && currentUserId === comment.user_id} + +
    + +
    + {#if avatarUrls[comment.user_id]} + {comment.username} + {:else} +
    + {initials(comment.username)} +
    + {/if} + {#if comment.username} + {comment.username} + {:else} + Anonymous + {/if} + · + {formatDate(comment.created)} +
    + + +

    {comment.body}

    + + +
    + + + + + + + + {#if isLoggedIn} + + {/if} + + + {#if isOwner} + + {/if} +
    + + + {#if replyingTo === comment.id} +
    + +
    + + {replyCharCount}/2000 + +
    + {#if replyError} + {replyError} + {/if} + + +
    +
    +
    + {/if} + + + {#if comment.replies && comment.replies.length > 0} +
    + {#each comment.replies as reply (reply.id)} + {@const replyVote = myVotes[reply.id]} + {@const replyVoting = votingIds.has(reply.id)} + {@const replyDeleting = deletingIds.has(reply.id)} + {@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id} + +
    + +
    + {#if avatarUrls[reply.user_id]} + {reply.username} + {:else} +
    + {initials(reply.username)} +
    + {/if} + {#if reply.username} + {reply.username} + {:else} + Anonymous + {/if} + · + {formatDate(reply.created)} +
    + + +

    {reply.body}

    + + +
    + + + + + {#if replyIsOwner} + + {/if} +
    +
    + {/each} +
    + {/if} +
    + {/each} +
    + {/if} +
    diff --git a/ui-v2/src/lib/index.ts b/ui-v2/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/ui-v2/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/ui-v2/src/lib/server/logger.ts b/ui-v2/src/lib/server/logger.ts new file mode 100644 index 0000000..898559f --- /dev/null +++ b/ui-v2/src/lib/server/logger.ts @@ -0,0 +1,37 @@ +/** + * Structured server-side logger. + * + * Emits JSON lines to stderr so they appear in container/process logs without + * polluting stdout (which Node's HTTP layer uses for responses). + * + * Format mirrors Go's log/slog default JSON output: + * {"time":"…","level":"ERROR","msg":"…","context":"pocketbase",...extra} + * + * Usage: + * import { log } from '$lib/server/logger'; + * log.error('pocketbase', 'auth failed', { status: 401, url }); + * log.warn('minio', 'presign slow', { slug, n, ms: elapsed }); + * log.info('auth', 'user registered', { username }); + */ + +type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; +type Extra = Record; + +function emit(level: Level, context: string, msg: string, extra?: Extra): void { + const entry: Record = { + time: new Date().toISOString(), + level, + context, + msg, + ...extra + }; + // Write to stderr — never stdout + process.stderr.write(JSON.stringify(entry) + '\n'); +} + +export const log = { + debug: (context: string, msg: string, extra?: Extra) => emit('DEBUG', context, msg, extra), + info: (context: string, msg: string, extra?: Extra) => emit('INFO', context, msg, extra), + warn: (context: string, msg: string, extra?: Extra) => emit('WARN', context, msg, extra), + error: (context: string, msg: string, extra?: Extra) => emit('ERROR', context, msg, extra), +}; diff --git a/ui-v2/src/lib/server/minio.ts b/ui-v2/src/lib/server/minio.ts new file mode 100644 index 0000000..c1c57fc --- /dev/null +++ b/ui-v2/src/lib/server/minio.ts @@ -0,0 +1,185 @@ +/** + * Server-side MinIO presign helper. + * Calls the scraper API to get presigned URLs, then optionally rewrites + * the MinIO host to the public-facing URL for browser use. + * + * Never import this from client-side code. + */ + +import { env } from '$env/dynamic/private'; +import { env as pubEnv } from '$env/dynamic/public'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; +// Public MinIO URL — used to rewrite presigned URLs so the browser can reach MinIO directly. +// In docker-compose this would differ from the internal endpoint. +const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000'; + +// ─── Avatar helpers ─────────────────────────────────────────────────────────── + +function extFromMime(mime: string): string { + if (mime.includes('png')) return 'png'; + if (mime.includes('webp')) return 'webp'; + if (mime.includes('gif')) return 'gif'; + return 'jpg'; +} + +/** + * Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO, + * along with the object key to record in PocketBase after upload completes. + * Routed through the Go scraper which holds MinIO credentials. + */ +export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> { + const ext = extFromMime(mimeType); + const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`presign avatar upload failed: ${res.status} ${body}`); + } + const data = (await res.json()) as { upload_url: string; key: string }; + return { uploadUrl: data.upload_url, key: data.key }; +} + +/** + * Returns a presigned GET URL for a user's avatar, rewritten to the public URL. + * Returns null if no avatar exists. + */ +export async function presignAvatarUrl(userId: string): Promise { + const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`); + if (res.status === 404) return null; + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`presign avatar failed: ${res.status} ${body}`); + } + const data = (await res.json()) as { url: string }; + return data.url ?? null; +} + +/** + * Rewrites the MinIO host in a presigned URL to the public-facing URL. + * + * The Go backend presigns URLs against its internal endpoint (e.g. minio:9000) + * when PUBLIC_MINIO_PUBLIC_URL is not set or equals the internal endpoint. + * In that case the browser must reach MinIO via the public URL (e.g. + * localhost:9000 in dev), so we swap the origin. + * + * NOTE: AWS Signature V4 DOES include the Host header in the canonical request + * (via X-Amz-SignedHeaders=host). Rewriting the host here would break the + * signature. This function is therefore only a no-op safety net — in + * production the Go backend is configured with MINIO_PUBLIC_ENDPOINT equal to + * the externally-reachable hostname, so presigned URLs already carry the right + * host and no rewrite is needed. + * + * For local dev: MINIO_PUBLIC_ENDPOINT=http://localhost:9000 and the backend + * presigns with localhost:9000 (the public client), so this rewrite is again + * a no-op (origins already match). + */ +function rewriteHost(presignedUrl: string): string { + try { + const u = new URL(presignedUrl); + const pub = new URL(MINIO_PUBLIC_URL); + // No-op if already pointing at the right origin. + if (u.protocol === pub.protocol && u.hostname === pub.hostname && u.port === pub.port) { + return presignedUrl; + } + u.protocol = pub.protocol; + u.hostname = pub.hostname; + u.port = pub.port; + return u.toString(); + } catch { + return presignedUrl; + } +} + +/** + * Returns a presigned URL for a chapter markdown file. + * URL is valid for ~15 minutes (set by the scraper). + * + * @param rewrite - if true, rewrites the MinIO host to PUBLIC_MINIO_PUBLIC_URL + * (for browser use). Defaults to false — the server-side load function fetches + * the URL directly from the internal MinIO endpoint. + */ +export async function presignChapter(slug: string, n: number, rewrite = false): Promise { + log.debug('minio', 'presigning chapter', { slug, n }); + let res: Response; + try { + res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`); + } catch (e) { + log.error('minio', 'presign chapter network error', { slug, n, err: String(e) }); + throw new Error(`presign chapter ${slug}/${n}: network error`); + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('minio', 'presign chapter failed', { slug, n, status: res.status, body }); + throw new Error(`presign chapter ${slug}/${n}: ${res.status}`); + } + const data = (await res.json()) as { url: string }; + log.debug('minio', 'presign chapter ok', { slug, n }); + return rewrite ? rewriteHost(data.url) : data.url; +} + +/** + * Returns a presigned URL for a voice sample audio file. + * URL is valid for ~1 hour. The URL is returned to the browser for direct streaming. + * Throws with { status: 404 } when the sample has not been generated yet. + */ +export async function presignVoiceSample(voice: string): Promise { + log.debug('minio', 'presigning voice sample', { voice }); + let res: Response; + try { + res = await fetch(`${SCRAPER_URL}/api/presign/voice-sample/${encodeURIComponent(voice)}`); + } catch (e) { + log.error('minio', 'presign voice sample network error', { voice, err: String(e) }); + throw new Error(`presign voice sample ${voice}: network error`); + } + if (res.status === 404) { + const err = new Error(`presign voice sample ${voice}: not found`) as Error & { status: number }; + err.status = 404; + throw err; + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('minio', 'presign voice sample failed', { voice, status: res.status, body }); + throw new Error(`presign voice sample ${voice}: ${res.status}`); + } + const data = (await res.json()) as { url: string }; + log.debug('minio', 'presign voice sample ok', { voice }); + return rewriteHost(data.url); +} + +/** + * Returns a presigned URL for an audio file. + * URL is valid for ~1 hour. The URL is returned to the browser for direct streaming. + * Throws with { status: 404 } when the audio object has not been generated yet. + */ +export async function presignAudio( + slug: string, + n: number, + voice?: string +): Promise { + const params = new URLSearchParams(); + if (voice) params.set('voice', voice); + const qs = params.toString() ? `?${params.toString()}` : ''; + log.debug('minio', 'presigning audio', { slug, n, voice }); + let res: Response; + try { + res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`); + } catch (e) { + log.error('minio', 'presign audio network error', { slug, n, err: String(e) }); + throw new Error(`presign audio ${slug}/${n}: network error`); + } + if (res.status === 404) { + // Audio hasn't been generated / uploaded yet — caller should surface this as 404. + const err = new Error(`presign audio ${slug}/${n}: not found`) as Error & { status: number }; + err.status = 404; + throw err; + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('minio', 'presign audio failed', { slug, n, status: res.status, body }); + throw new Error(`presign audio ${slug}/${n}: ${res.status}`); + } + const data = (await res.json()) as { url: string }; + log.debug('minio', 'presign audio ok', { slug, n }); + return rewriteHost(data.url); +} diff --git a/ui-v2/src/lib/server/pocketbase.ts b/ui-v2/src/lib/server/pocketbase.ts new file mode 100644 index 0000000..9388cf2 --- /dev/null +++ b/ui-v2/src/lib/server/pocketbase.ts @@ -0,0 +1,1349 @@ +/** + * Server-side PocketBase client. + * Uses admin credentials — never import this from client-side code. + * All methods talk directly to PocketBase REST API. + */ + +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const PB_URL = env.POCKETBASE_URL ?? 'http://localhost:8090'; +const PB_EMAIL = env.POCKETBASE_ADMIN_EMAIL ?? 'admin@libnovel.local'; +const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Book { + id: string; + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[] | string; + summary: string; + total_chapters: number; + source_url: string; + ranking: number; + meta_updated: string; +} + +export interface ChapterIdx { + id: string; + slug: string; + number: number; + title: string; + date_label: string; +} + +export interface Progress { + id?: string; + session_id: string; + user_id?: string; + slug: string; + chapter: number; + audio_time?: number; + updated: string; +} + +export interface UserSettings { + id?: string; + session_id: string; + user_id?: string; + auto_next: boolean; + voice: string; + speed: number; + updated?: string; +} + +export interface User { + id: string; + username: string; + password_hash: string; + role: string; + created: string; + avatar_url?: string; +} + +// ─── Auth token cache ───────────────────────────────────────────────────────── + +let _token = ''; +let _tokenExp = 0; + +async function getToken(): Promise { + if (_token && Date.now() < _tokenExp) return _token; + + log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL }); + + const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }) + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'admin auth failed', { status: res.status, url: PB_URL, body }); + throw new Error(`PocketBase auth failed: ${res.status} — ${body}`); + } + + const data = await res.json(); + _token = data.token as string; + _tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours + log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL }); + return _token; +} + +// ─── Generic helpers ────────────────────────────────────────────────────────── + +async function pbGet(path: string): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}${path}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'GET failed', { path, status: res.status, body }); + throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`); + } + return res.json() as Promise; +} + +async function pbPost(path: string, body: unknown): Promise { + const token = await getToken(); + return fetch(`${PB_URL}${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +async function pbPatch(path: string, body: unknown): Promise { + const token = await getToken(); + return fetch(`${PB_URL}${path}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +async function pbDelete(path: string): Promise { + const token = await getToken(); + return fetch(`${PB_URL}${path}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); +} + +interface PBList { + items: T[]; + totalItems: number; +} + +async function listAll(collection: string, filter = '', sort = ''): Promise { + const perPage = 500; + const params = new URLSearchParams({ perPage: String(perPage), page: '1' }); + if (filter) params.set('filter', filter); + if (sort) params.set('sort', sort); + + const first = await pbGet>( + `/api/collections/${collection}/records?${params.toString()}` + ); + const items: T[] = first.items ?? []; + const total = first.totalItems ?? 0; + + // Fetch remaining pages if there are more records than the first page holds. + const totalPages = Math.ceil(total / perPage); + for (let page = 2; page <= totalPages; page++) { + params.set('page', String(page)); + const data = await pbGet>( + `/api/collections/${collection}/records?${params.toString()}` + ); + items.push(...(data.items ?? [])); + } + + return items; +} + +async function listN(collection: string, n: number, filter = '', sort = ''): Promise { + const params = new URLSearchParams({ perPage: String(n) }); + if (filter) params.set('filter', filter); + if (sort) params.set('sort', sort); + const data = await pbGet>( + `/api/collections/${collection}/records?${params.toString()}` + ); + return data.items ?? []; +} + +async function countCollection(collection: string, filter = ''): Promise { + const params = new URLSearchParams({ perPage: '1' }); + if (filter) params.set('filter', filter); + const data = await pbGet>( + `/api/collections/${collection}/records?${params.toString()}` + ); + return (data as { totalItems: number }).totalItems ?? 0; +} + +async function listOne(collection: string, filter: string): Promise { + const params = new URLSearchParams({ perPage: '1', filter }); + const data = await pbGet>( + `/api/collections/${collection}/records?${params.toString()}` + ); + return data.items[0] ?? null; +} + +// ─── Books ──────────────────────────────────────────────────────────────────── + +export async function listBooks(): Promise { + const books = await listAll('books', '', '+title'); + const nullTitles = books.filter((b) => b.title == null).length; + if (nullTitles > 0) { + log.warn('pocketbase', 'listBooks: books with null title', { count: nullTitles, total: books.length }); + } + log.debug('pocketbase', 'listBooks', { total: books.length, nullTitles }); + return books; +} + +export async function getBook(slug: string): Promise { + return listOne('books', `slug="${slug}"`); +} + +export async function recentlyAddedBooks(limit = 6): Promise { + return listN('books', limit, '', '-meta_updated'); +} + +export async function recentlyUpdatedBooks(limit = 6): Promise { + return listN('books', limit, '', '-meta_updated'); +} + +export interface HomeStats { + totalBooks: number; + totalChapters: number; +} + +export async function getHomeStats(): Promise { + const [totalBooks, totalChapters] = await Promise.all([ + countCollection('books'), + countCollection('chapters_idx') + ]); + return { totalBooks, totalChapters }; +} + +// ─── Chapter index ──────────────────────────────────────────────────────────── + +export async function listChapterIdx(slug: string): Promise { + return listAll('chapters_idx', `slug="${slug}"`, '+number'); +} + +// ─── Reading progress ───────────────────────────────────────────────────────── + +/** + * Build the PocketBase filter string for a progress lookup. + * When userId is set, keyed by user_id (portable across devices). + * When only sessionId is set, keyed by session_id (anonymous). + */ +function progressFilter(sessionId: string, slug: string, userId?: string): string { + if (userId) return `user_id="${userId}"&&slug="${slug}"`; + return `session_id="${sessionId}"&&slug="${slug}"`; +} + +function allProgressFilter(sessionId: string, userId?: string): string { + if (userId) return `user_id="${userId}"`; + return `session_id="${sessionId}"`; +} + +export async function getProgress( + sessionId: string, + slug: string, + userId?: string +): Promise { + return listOne('progress', progressFilter(sessionId, slug, userId)); +} + +export async function allProgress(sessionId: string, userId?: string): Promise { + return listAll('progress', allProgressFilter(sessionId, userId), '-updated'); +} + +export async function setProgress( + sessionId: string, + slug: string, + chapter: number, + userId?: string +): Promise { + const existing = await listOne( + 'progress', + progressFilter(sessionId, slug, userId) + ); + + const payload: Partial = { + session_id: sessionId, + slug, + chapter, + updated: new Date().toISOString() + }; + if (userId) payload.user_id = userId; + + if (existing) { + const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'setProgress PATCH failed', { slug, chapter, status: res.status, body }); + } + } else { + const res = await pbPost('/api/collections/progress/records', payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body }); + } + } +} + +/** + * Delete progress entry for a specific book (removes from library/continue reading). + */ +export async function deleteProgress( + sessionId: string, + slug: string, + userId?: string +): Promise { + const existing = await listOne( + 'progress', + progressFilter(sessionId, slug, userId) + ); + + if (!existing) { + log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId }); + return; + } + + const res = await pbDelete(`/api/collections/progress/records/${existing.id}`); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'deleteProgress failed', { + slug, + id: existing.id, + status: res.status, + body + }); + throw new Error(`Failed to delete progress: ${res.status}`); + } + log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id }); +} + +/** + * Merge anonymous session progress into a user account on login/register. + * + * For each book tracked under sessionId, upserts a user-keyed record keeping + * whichever chapter is more recent (or higher if timestamps are equal). + * This makes progress portable across devices for logged-in users. + */ +export async function mergeSessionProgress(sessionId: string, userId: string): Promise { + let sessionRows: Progress[]; + try { + sessionRows = await allProgress(sessionId); + } catch (e) { + log.warn('pocketbase', 'mergeSessionProgress: failed to read session progress', { + sessionId, + err: String(e) + }); + return; + } + if (sessionRows.length === 0) return; + + for (const row of sessionRows) { + try { + const userRow = await listOne( + 'progress', + `user_id="${userId}"&&slug="${row.slug}"` + ); + + // Keep the record with the more recent update (or higher chapter if timestamps match) + const sessionTs = row.updated ? new Date(row.updated).getTime() : 0; + const userTs = userRow?.updated ? new Date(userRow.updated).getTime() : 0; + const shouldOverwrite = !userRow || sessionTs > userTs || + (sessionTs === userTs && row.chapter > (userRow?.chapter ?? 0)); + + if (shouldOverwrite) { + const payload: Partial = { + session_id: sessionId, + user_id: userId, + slug: row.slug, + chapter: row.chapter, + updated: row.updated ?? new Date().toISOString() + }; + if (userRow) { + await pbPatch(`/api/collections/progress/records/${userRow.id}`, payload); + } else { + await pbPost('/api/collections/progress/records', payload); + } + } + } catch (e) { + log.warn('pocketbase', 'mergeSessionProgress: failed to merge row', { + slug: row.slug, + err: String(e) + }); + } + } + log.info('pocketbase', 'mergeSessionProgress: done', { sessionId, userId, count: sessionRows.length }); +} + +// ─── User library (saved books) ─────────────────────────────────────────────── + +export interface UserLibraryEntry { + id?: string; + session_id: string; + user_id?: string; + slug: string; + saved_at: string; +} + +function libraryFilter(sessionId: string, userId?: string): string { + if (userId) return `user_id="${userId}"`; + return `session_id="${sessionId}"`; +} + +/** Returns all slugs the user has explicitly saved to their library. */ +export async function getSavedSlugs(sessionId: string, userId?: string): Promise> { + const rows = await listAll( + 'user_library', + libraryFilter(sessionId, userId) + ); + return new Set(rows.map((r) => r.slug)); +} + +/** Returns whether a specific slug is saved. */ +export async function isBookSaved( + sessionId: string, + slug: string, + userId?: string +): Promise { + const filter = userId + ? `user_id="${userId}"&&slug="${slug}"` + : `session_id="${sessionId}"&&slug="${slug}"`; + const row = await listOne('user_library', filter); + return row !== null; +} + +/** Save a book to the user's library. No-op if already saved. */ +export async function saveBook( + sessionId: string, + slug: string, + userId?: string +): Promise { + const alreadySaved = await isBookSaved(sessionId, slug, userId); + if (alreadySaved) return; + const payload: Partial = { + session_id: sessionId, + slug, + saved_at: new Date().toISOString() + }; + if (userId) payload.user_id = userId; + const res = await pbPost('/api/collections/user_library/records', payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body }); + } +} + +/** Remove a book from the user's library. */ +export async function unsaveBook( + sessionId: string, + slug: string, + userId?: string +): Promise { + const filter = userId + ? `user_id="${userId}"&&slug="${slug}"` + : `session_id="${sessionId}"&&slug="${slug}"`; + const row = await listOne('user_library', filter); + if (!row) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_library/records/${row.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); +} + +// ─── Users ──────────────────────────────────────────────────────────────────── + +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(password, salt, 64).toString('hex'); + return `${salt}:${hash}`; +} + +function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split(':'); + if (!salt || !hash) return false; + const derived = scryptSync(password, salt, 64); + const hashBuf = Buffer.from(hash, 'hex'); + if (derived.length !== hashBuf.length) return false; + return timingSafeEqual(derived, hashBuf); +} + +/** + * Look up a user by username. Returns null if not found. + */ +export async function getUserByUsername(username: string): Promise { + return listOne('app_users', `username="${username.replace(/"/g, '\\"')}"`); +} + +/** + * Create a new user with a hashed password. Throws if username already exists. + */ +export async function createUser(username: string, password: string, role = 'user'): Promise { + log.info('pocketbase', 'createUser: checking for existing username', { username }); + const existing = await getUserByUsername(username); + if (existing) { + log.warn('pocketbase', 'createUser: username already taken', { username }); + throw new Error('Username already taken'); + } + const password_hash = hashPassword(password); + log.info('pocketbase', 'createUser: inserting new user', { username, role }); + const res = await pbPost('/api/collections/app_users/records', { + username, + password_hash, + role, + created: new Date().toISOString() + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'createUser: PocketBase rejected record', { + username, + status: res.status, + body + }); + throw new Error(`Failed to create user: ${res.status} ${body}`); + } + log.info('pocketbase', 'createUser: user created', { username, role }); + return res.json() as Promise; +} + +/** + * Change a user's password. Verifies the current password first. + * Returns true on success, false if currentPassword is wrong. + * Throws on unexpected errors. + */ +export async function changePassword( + userId: string, + currentPassword: string, + newPassword: string +): Promise { + // Fetch the user record directly by id to verify current password + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body }); + throw new Error(`Failed to fetch user: ${res.status}`); + } + const user = (await res.json()) as User; + if (!verifyPassword(currentPassword, user.password_hash)) { + log.warn('pocketbase', 'changePassword: wrong current password', { userId }); + return false; + } + const newHash = hashPassword(newPassword); + const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, { + password_hash: newHash + }); + if (!patch.ok) { + const body = await patch.text().catch(() => ''); + log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body }); + throw new Error(`Failed to update password: ${patch.status}`); + } + log.info('pocketbase', 'changePassword: success', { userId }); + return true; +} + +/** + * Verify username + password. Returns the user on success, null on failure. + */ +export async function loginUser(username: string, password: string): Promise { + log.debug('pocketbase', 'loginUser: lookup', { username }); + const user = await getUserByUsername(username); + if (!user) { + log.warn('pocketbase', 'loginUser: username not found', { username }); + return null; + } + const ok = verifyPassword(password, user.password_hash); + if (!ok) { + log.warn('pocketbase', 'loginUser: wrong password', { username }); + return null; + } + log.info('pocketbase', 'loginUser: success', { username, role: user.role }); + return user; +} + +// ─── User settings ──────────────────────────────────────────────────────────── + +function settingsFilter(sessionId: string, userId?: string): string { + if (userId) return `user_id="${userId}"`; + return `session_id="${sessionId}"`; +} + +export async function getSettings( + sessionId: string, + userId?: string +): Promise { + return listOne('user_settings', settingsFilter(sessionId, userId)); +} + +export async function saveSettings( + sessionId: string, + settings: { autoNext: boolean; voice: string; speed: number }, + userId?: string +): Promise { + const existing = await listOne( + 'user_settings', + settingsFilter(sessionId, userId) + ); + + const payload: Partial = { + session_id: sessionId, + auto_next: settings.autoNext, + voice: settings.voice, + speed: settings.speed, + updated: new Date().toISOString() + }; + if (userId) payload.user_id = userId; + + if (existing) { + const res = await pbPatch(`/api/collections/user_settings/records/${existing.id}`, payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'saveSettings PATCH failed', { status: res.status, body }); + } + } else { + const res = await pbPost('/api/collections/user_settings/records', payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'saveSettings POST failed', { status: res.status, body }); + } + } +} + +// ─── Audio time ─────────────────────────────────────────────────────────────── + +export async function setAudioTime( + sessionId: string, + slug: string, + chapter: number, + audioTime: number, + userId?: string +): Promise { + const existing = await listOne( + 'progress', + progressFilter(sessionId, slug, userId) + ); + if (!existing) { + // No progress record yet — create one with audio_time + const payload: Partial = { + session_id: sessionId, + slug, + chapter, + audio_time: audioTime, + updated: new Date().toISOString() + }; + if (userId) payload.user_id = userId; + const res = await pbPost('/api/collections/progress/records', payload); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'setAudioTime POST failed', { slug, chapter, status: res.status, body }); + } + return; + } + const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, { + audio_time: audioTime, + updated: new Date().toISOString() + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'setAudioTime PATCH failed', { slug, chapter, status: res.status, body }); + } +} + +// ─── Audio cache ────────────────────────────────────────────────────────────── + +export interface AudioCacheEntry { + id: string; + cache_key: string; + filename: string; + updated: string; +} + +export async function listAudioCache(): Promise { + return listAll('audio_cache', '', '-updated'); +} + +// ─── Scraping tasks ─────────────────────────────────────────────────────────── + +export interface ScrapingTask { + id: string; + kind: string; + target_url: string; + status: string; + books_found: number; + chapters_scraped: number; + chapters_skipped: number; + errors: number; + started: string; + finished: string; + error_message: string; +} + +export async function listScrapingTasks(): Promise { + return listAll('scraping_tasks', '', '-started'); +} + +// ─── Audio jobs ─────────────────────────────────────────────────────────────── + +export interface AudioJob { + id: string; + cache_key: string; // "slug/chapter/voice" + slug: string; + chapter: number; + voice: string; + status: string; // "pending" | "generating" | "done" | "failed" + error_message: string; + started: string; + finished: string; +} + +export async function listAudioJobs(): Promise { + return listAll('audio_jobs', '', '-started'); +} + +export async function getAudioTime( + sessionId: string, + slug: string, + chapter: number, + userId?: string +): Promise { + const row = await listOne('progress', progressFilter(sessionId, slug, userId)); + if (!row || !row.audio_time) return null; + return row.audio_time; +} + +// ─── User sessions ──────────────────────────────────────────────────────────── + +export interface UserSession { + id: string; + user_id: string; + session_id: string; // the auth session ID embedded in the token + user_agent: string; + ip: string; + created_at: string; + last_seen: string; +} + +/** + * Create a new session record on login. Returns the record ID. + */ +export async function createUserSession( + userId: string, + authSessionId: string, + userAgent: string, + ip: string +): Promise { + const now = new Date().toISOString(); + const res = await pbPost('/api/collections/user_sessions/records', { + user_id: userId, + session_id: authSessionId, + user_agent: userAgent, + ip, + created_at: now, + last_seen: now + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body }); + throw new Error(`Failed to create session: ${res.status}`); + } + const rec = (await res.json()) as { id: string }; + return rec.id; +} + +/** + * Update last_seen on a session (best-effort, non-fatal if it fails). + */ +export async function touchUserSession(authSessionId: string): Promise { + const row = await listOne( + 'user_sessions', + `session_id="${authSessionId}"` + ); + if (!row) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ last_seen: new Date().toISOString() }) + }); +} + +/** + * Check whether a session has been revoked (i.e., not present in DB). + * Returns true if revoked/missing, false if valid. + */ +export async function isSessionRevoked(authSessionId: string): Promise { + const row = await listOne('user_sessions', `session_id="${authSessionId}"`); + return row === null; +} + +/** + * List all active sessions for a user. + */ +export async function listUserSessions(userId: string): Promise { + return listAll('user_sessions', `user_id="${userId}"`, '-last_seen'); +} + +/** + * Revoke (delete) a specific session by its PocketBase record ID. + * Only allows deletion if the session belongs to the given userId. + */ +export async function revokeUserSession(recordId: string, userId: string): Promise { + // Verify ownership before deleting + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) return false; + const rec = (await res.json()) as UserSession; + if (rec.user_id !== userId) return false; + + const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + return del.ok || del.status === 204; +} + +/** + * Revoke all sessions for a user (used on password change etc). + */ +export async function revokeAllUserSessions(userId: string): Promise { + const sessions = await listUserSessions(userId); + const token = await getToken(); + await Promise.all( + sessions.map((s) => + fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }).catch(() => {}) + ) + ); +} + +/** + * Update the avatar_url field for a user record. + */ +export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ avatar_url: avatarUrl }) + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`); + } +} + +// ─── Comments ───────────────────────────────────────────────────────────────── + +export interface BookComment { + id: string; + slug: string; + user_id: string; + username: string; + body: string; + upvotes: number; + downvotes: number; + created: string; + parent_id?: string; // empty / absent = top-level; set = reply +} + +export interface CommentVote { + id: string; + comment_id: string; + user_id: string; + session_id: string; + vote: 'up' | 'down'; +} + +export type CommentSort = 'top' | 'new'; + +/** + * List top-level comments for a book. + * sort='top' → by net score (upvotes − downvotes) desc, then newest + * sort='new' → newest first (default) + * Replies (parent_id != "") are NOT included — fetch them separately. + */ +export async function listComments( + slug: string, + sort: CommentSort = 'new' +): Promise { + const token = await getToken(); + const slugEsc = slug.replace(/"/g, '\\"'); + // Only top-level comments (parent_id is empty or missing) + const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`); + // PocketBase sorts: for 'top' we still fetch all and re-sort in JS because + // PocketBase doesn't support computed sort fields. For 'new' we push the + // sort down to the DB so large result sets are still paged correctly. + const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created'; + const res = await fetch( + `${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return []; + const data = await res.json(); + let items = (data.items ?? []) as BookComment[]; + if (sort === 'top') { + items = items.sort((a, b) => { + const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0); + const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0); + if (scoreB !== scoreA) return scoreB - scoreA; + // tie-break: newest first + return new Date(b.created).getTime() - new Date(a.created).getTime(); + }); + } + return items; +} + +/** + * List replies (1-level deep) for a single parent comment. + * Always sorted oldest-first so the conversation reads naturally. + */ +export async function listReplies(parentId: string): Promise { + const token = await getToken(); + const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`); + const res = await fetch( + `${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return []; + const data = await res.json(); + return (data.items ?? []) as BookComment[]; +} + +/** + * Create a new comment. Returns the created record. + * Pass parentId to create a reply; omit / pass undefined for a top-level comment. + */ +export async function createComment( + slug: string, + body: string, + userId: string | undefined, + username: string, + parentId?: string +): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug, + body, + user_id: userId ?? '', + username, + upvotes: 0, + downvotes: 0, + parent_id: parentId ?? '', + created: new Date().toISOString() + }) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`createComment failed: ${res.status} ${text}`); + } + return res.json() as Promise; +} + +/** + * Delete a comment (and optionally its replies) by ID. + * Only the comment owner (matched by userId) may delete. + * Throws if the comment doesn't exist or the user doesn't own it. + */ +export async function deleteComment(commentId: string, userId: string): Promise { + const token = await getToken(); + + // Fetch the comment to verify ownership + const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`); + const comment = (await getRes.json()) as BookComment; + if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment'); + + // Delete any replies first + const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`); + const repliesRes = await fetch( + `${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (repliesRes.ok) { + const repliesData = await repliesRes.json(); + const replies = (repliesData.items ?? []) as BookComment[]; + await Promise.all( + replies.map((r) => + fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }) + ) + ); + } + + // Delete the comment itself + const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`); +} + +/** + * Get an existing vote by this voter (identified by user_id or session_id) on a comment. + */ +export async function getCommentVote( + commentId: string, + sessionId: string, + userId?: string +): Promise { + const token = await getToken(); + const voterFilter = userId + ? `comment_id="${commentId}"&&user_id="${userId}"` + : `comment_id="${commentId}"&&session_id="${sessionId}"`; + const res = await fetch( + `${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return null; + const data = await res.json(); + const items = (data.items ?? []) as CommentVote[]; + return items[0] ?? null; +} + +/** + * Cast or change a vote on a comment. Handles: + * - New vote: creates vote record, increments counter. + * - Same vote again: removes it (toggle off), decrements counter. + * - Changed vote: updates record, adjusts both counters. + * Returns the updated comment. + */ +export async function voteComment( + commentId: string, + vote: 'up' | 'down', + sessionId: string, + userId?: string +): Promise { + const token = await getToken(); + + // Fetch current comment + const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`); + const comment = (await commentRes.json()) as BookComment; + + const existing = await getCommentVote(commentId, sessionId, userId); + + let upDelta = 0; + let downDelta = 0; + + if (!existing) { + // New vote + await fetch(`${PB_URL}/api/collections/comment_votes/records`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote }) + }); + vote === 'up' ? upDelta++ : downDelta++; + } else if (existing.vote === vote) { + // Toggle off — remove vote + await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + vote === 'up' ? upDelta-- : downDelta--; + } else { + // Changed vote + await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ vote }) + }); + if (vote === 'up') { upDelta++; downDelta--; } + else { upDelta--; downDelta++; } + } + + // Patch comment counters + const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta), + downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta) + }) + }); + if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`); + return patchRes.json() as Promise; +} + +/** + * Fetch votes cast by this session/user, keyed by comment_id. + * Returns a map of commentId → 'up' | 'down'. + */ +export async function getMyVotes( + commentIds: string[], + sessionId: string, + userId?: string +): Promise> { + if (commentIds.length === 0) return {}; + const token = await getToken(); + const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||'); + const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`; + const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`); + const res = await fetch( + `${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!res.ok) return {}; + const data = await res.json(); + const map: Record = {}; + for (const v of (data.items ?? []) as CommentVote[]) { + map[v.comment_id] = v.vote as 'up' | 'down'; + } + return map; +} + +// ─── User subscriptions ─────────────────────────────────────────────────────── + +export interface UserSubscription { + id: string; + follower_id: string; + followee_id: string; + created: string; +} + +/** + * Returns the subscription record if follower_id follows followee_id, else null. + */ +export async function getSubscription( + followerId: string, + followeeId: string +): Promise { + const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`); + const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>( + `/api/collections/user_subscriptions/records?filter=${filter}&perPage=1` + ).catch(() => null); + return res?.items?.[0] ?? null; +} + +/** + * Subscribe follower_id to followee_id. No-ops if already subscribed. + * Returns the subscription record. + */ +export async function subscribe(followerId: string, followeeId: string): Promise { + const existing = await getSubscription(followerId, followeeId); + if (existing) return; + const res = await pbPost('/api/collections/user_subscriptions/records', { + follower_id: followerId, + followee_id: followeeId, + created: new Date().toISOString() + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to subscribe: ${res.status} — ${body}`); + } +} + +/** + * Unsubscribe follower_id from followee_id. No-ops if not subscribed. + */ +export async function unsubscribe(followerId: string, followeeId: string): Promise { + const existing = await getSubscription(followerId, followeeId); + if (!existing) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); +} + +/** + * Returns the list of user IDs that followerId is subscribed to. + */ +export async function getFollowingIds(followerId: string): Promise { + const items = await listAll( + 'user_subscriptions', + `follower_id="${followerId}"`, + '-created' + ).catch(() => [] as UserSubscription[]); + return items.map((s) => s.followee_id); +} + +/** + * Returns the count of subscribers (followers) for a given user. + */ +export async function getFollowerCount(followeeId: string): Promise { + return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0); +} + +/** + * Returns the count of accounts a user is following. + */ +export async function getFollowingCount(followerId: string): Promise { + return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0); +} + +/** + * Public profile data for a user. + */ +export interface PublicProfile { + id: string; + username: string; + avatar_url?: string; + created: string; + followerCount: number; + followingCount: number; +} + +/** + * Returns a user's public profile (no sensitive fields) by username. + */ +export async function getPublicProfile(username: string): Promise { + const user = await getUserByUsername(username); + if (!user) return null; + const [followerCount, followingCount] = await Promise.all([ + getFollowerCount(user.id), + getFollowingCount(user.id) + ]); + return { + id: user.id, + username: user.username, + avatar_url: user.avatar_url, + created: user.created, + followerCount, + followingCount + }; +} + +/** + * Returns a user's public library: books they have saved or are reading. + * Only includes books with progress or explicit saves (user_library). + */ +export async function getUserPublicLibrary( + userId: string +): Promise> { + const [allBooks, progressList, savedEntries] = await Promise.all([ + listBooks(), + listAll('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]), + listAll<{ id: string; slug: string; saved_at: string }>( + 'user_library', + `user_id="${userId}"`, + '-saved_at' + ).catch(() => [] as { id: string; slug: string; saved_at: string }[]) + ]); + + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = []; + const seen = new Set(); + + // Books with progress first (most recently read) + for (const p of progressList) { + const book = bookMap.get(p.slug); + if (!book || seen.has(p.slug)) continue; + seen.add(p.slug); + result.push({ book, chapter: p.chapter, saved: false }); + } + + // Saved-only books next + for (const e of savedEntries) { + const book = bookMap.get(e.slug); + if (!book || seen.has(e.slug)) continue; + seen.add(e.slug); + result.push({ book, chapter: null, saved: true }); + } + + // Mark saved flag for books that are both in progress AND saved + const savedSlugs = new Set(savedEntries.map((e) => e.slug)); + return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) })); +} + +/** + * Returns the currently-reading books (books with progress, not completed) + * for a given user ID. + */ +export async function getUserCurrentlyReading( + userId: string +): Promise> { + const [allBooks, progressList] = await Promise.all([ + listBooks(), + listAll('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]) + ]); + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + return progressList + .filter((p) => { + const book = bookMap.get(p.slug); + return book && p.chapter > 0 && p.chapter < book.total_chapters; + }) + .slice(0, 10) + .map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter })); +} + +/** + * Returns recently-updated books from ALL users that followerId is subscribed to. + * Deduplicates across followed users; sorts by most recently updated. + */ +export async function getSubscriptionFeed( + followerId: string, + limit = 12 +): Promise> { + const followingIds = await getFollowingIds(followerId); + if (followingIds.length === 0) return []; + + // Fetch all users we follow (for display names) + const token = await getToken(); + const userFetches = followingIds.map((id) => + fetch(`${PB_URL}/api/collections/app_users/records/${id}`, { + headers: { Authorization: `Bearer ${token}` } + }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null) + ); + const users = (await Promise.all(userFetches)).filter(Boolean) as User[]; + const userMap = new Map(users.map((u) => [u.id, u])); + + // Fetch progress for each followed user + const progressFetches = followingIds.map((id) => + listAll('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[]) + ); + const allProgressArrays = await Promise.all(progressFetches); + + const allBooks = await listBooks(); + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + + // Merge: per slug take the most-recent progress entry + const seen = new Set(); + const feed: Array<{ book: Book; readerUsername: string; updated: string }> = []; + + for (let i = 0; i < followingIds.length; i++) { + const uid = followingIds[i]; + const username = userMap.get(uid)?.username ?? 'unknown'; + for (const p of allProgressArrays[i]) { + if (seen.has(p.slug)) continue; + const book = bookMap.get(p.slug); + if (!book) continue; + seen.add(p.slug); + feed.push({ book, readerUsername: username, updated: p.updated }); + } + } + + // Sort by most recently read across all followed users + feed.sort((a, b) => b.updated.localeCompare(a.updated)); + return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername })); +} diff --git a/ui-v2/src/lib/server/presignCache.ts b/ui-v2/src/lib/server/presignCache.ts new file mode 100644 index 0000000..8940e18 --- /dev/null +++ b/ui-v2/src/lib/server/presignCache.ts @@ -0,0 +1,96 @@ +/** + * In-process presign URL cache. + * + * MinIO presigned audio URLs are valid for 1 hour (set by the backend). + * We cache them for 50 minutes so the browser always gets a URL with at + * least 10 minutes of remaining validity, while avoiding a round-trip to + * the backend + MinIO presign API on every "Play" click. + * + * The cache is a plain Map in the Node.js module scope — it lives for the + * lifetime of the SvelteKit server process and is shared across all requests. + * No persistence, no distributed cache needed: each SvelteKit instance + * maintains its own cache and entries expire naturally. + * + * Voice-sample URLs use the same cache with key "sample:". + */ + +const AUDIO_TTL_MS = 50 * 60 * 1000; // 50 minutes + +interface CacheEntry { + url: string; + expiresAt: number; // Date.now() ms +} + +const cache = new Map(); + +// ── Periodic sweep ──────────────────────────────────────────────────────────── +// Remove stale entries every 10 minutes so the Map doesn't grow unboundedly +// in long-running processes. Uses unref() so it never prevents Node from +// exiting cleanly. +let sweepTimer: ReturnType | null = null; + +function startSweep() { + if (sweepTimer) return; + sweepTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of cache) { + if (entry.expiresAt <= now) cache.delete(key); + } + }, 10 * 60 * 1000); + // Don't block Node.js exit + sweepTimer.unref?.(); +} + +startSweep(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** Cache key for a chapter audio presigned URL. */ +export function audioKey(slug: string, n: number, voice: string): string { + return `audio:${slug}:${n}:${voice}`; +} + +/** Cache key for a voice-sample presigned URL. */ +export function sampleKey(voice: string): string { + return `sample:${voice}`; +} + +/** Return the cached URL for key, or null if absent / expired. */ +export function get(key: string): string | null { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + cache.delete(key); + return null; + } + return entry.url; +} + +/** Store a presigned URL under key for TTL_MS milliseconds. */ +export function set(key: string, url: string, ttlMs = AUDIO_TTL_MS): void { + cache.set(key, { url, expiresAt: Date.now() + ttlMs }); +} + +/** Invalidate a specific key (e.g. after audio generation to force refresh). */ +export function invalidate(key: string): void { + cache.delete(key); +} + +/** Drain all entries — called on graceful shutdown to release memory. */ +export function drain(): void { + cache.clear(); + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } +} + +/** Current number of live (non-expired) cached entries. For health/debug. */ +export function size(): number { + const now = Date.now(); + let n = 0; + for (const entry of cache.values()) { + if (entry.expiresAt > now) n++; + } + return n; +} diff --git a/ui-v2/src/routes/+layout.server.ts b/ui-v2/src/routes/+layout.server.ts new file mode 100644 index 0000000..bafc1b4 --- /dev/null +++ b/ui-v2/src/routes/+layout.server.ts @@ -0,0 +1,32 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +import { getSettings } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +// Routes that are accessible without being logged in +const PUBLIC_ROUTES = new Set(['/login']); + +export const load: LayoutServerLoad = async ({ locals, url }) => { + if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) { + redirect(302, `/login`); + } + + let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 }; + try { + const row = await getSettings(locals.sessionId, locals.user?.id); + if (row) { + settings = { + autoNext: row.auto_next ?? false, + voice: row.voice ?? 'af_bella', + speed: row.speed ?? 1.0 + }; + } + } catch (e) { + log.warn('layout', 'failed to load settings', { err: String(e) }); + } + + return { + user: locals.user, + settings + }; +}; diff --git a/ui-v2/src/routes/+layout.svelte b/ui-v2/src/routes/+layout.svelte new file mode 100644 index 0000000..b4e93cc --- /dev/null +++ b/ui-v2/src/routes/+layout.svelte @@ -0,0 +1,628 @@ + + + + + + libnovel + + + + + +
    + + {#if navigating} +
    +
    +
    + {/if} +
    + + + + {#if data.user && menuOpen} +
    + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" + > + Library + + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/browse') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" + > + Discover + + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" + > + Profile ({data.user.username}) + + {#if data.user?.role === 'admin'} +
    +

    Admin

    + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" + > + Scrape tasks + + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" + > + Audio cache + + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" + > + Audio jobs + + {/if} +
    +
    + +
    +
    + {/if} +
    + +
    + {#key page.url.pathname + page.url.search} + {@render children()} + {/key} +
    + + +
    + + +{#if audioStore.active} +
    + + + {#if chapterDrawerOpen && audioStore.chapters.length > 0} +
    +
    +
    + Chapters + +
    + {#each audioStore.chapters as ch (ch.number)} + (chapterDrawerOpen = false)} + class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter + ? 'text-amber-400 font-semibold' + : 'text-zinc-400'}" + > + + {ch.number} + + {ch.title || `Chapter ${ch.number}`} + {#if ch.number === audioStore.chapter} + + + + {/if} + + {/each} +
    +
    + {/if} + + + {#if audioStore.status === 'generating' || audioStore.status === 'loading'} +
    +
    +
    + {:else if audioStore.status === 'ready'} + +
    + +
    + {/if} + +
    + + + + + {#if audioStore.status === 'ready'} + + + + + + + + + + + + + + + {:else if audioStore.status === 'generating'} + + + + + + {/if} + + + {#if audioStore.slug && audioStore.chapter > 0} + + {#if audioStore.cover} + + {:else} + +
    + + + +
    + {/if} +
    + {/if} + + + +
    +
    +{/if} diff --git a/ui-v2/src/routes/+page.server.ts b/ui-v2/src/routes/+page.server.ts new file mode 100644 index 0000000..abd05b8 --- /dev/null +++ b/ui-v2/src/routes/+page.server.ts @@ -0,0 +1,59 @@ +import type { PageServerLoad } from './$types'; +import { + listBooks, + recentlyAddedBooks, + allProgress, + getHomeStats, + getSubscriptionFeed +} from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import type { Book, Progress } from '$lib/server/pocketbase'; + +export const load: PageServerLoad = async ({ locals }) => { + let allBooks: Book[] = []; + let recentBooks: Book[] = []; + let progressList: Progress[] = []; + let stats = { totalBooks: 0, totalChapters: 0 }; + + try { + [allBooks, recentBooks, progressList, stats] = await Promise.all([ + listBooks(), + recentlyAddedBooks(8), + allProgress(locals.sessionId, locals.user?.id), + getHomeStats() + ]); + } catch (e) { + log.error('home', 'failed to load home data', { err: String(e) }); + } + + // Build slug → book lookup + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + + // Continue reading: progress entries joined with book data, most recent first + const continueReading = progressList + .filter((p) => bookMap.has(p.slug)) + .slice(0, 6) + .map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter })); + + // Recently updated: deduplicate against continueReading slugs + const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); + const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); + + // Subscription feed — only when logged in + const subscriptionFeed = locals.user + ? await getSubscriptionFeed(locals.user.id, 12).catch((e) => { + log.error('home', 'failed to load subscription feed', { err: String(e) }); + return [] as Awaited>; + }) + : []; + + return { + continueReading, + recentlyUpdated, + subscriptionFeed, + stats: { + ...stats, + booksInProgress: continueReading.length + } + }; +}; diff --git a/ui-v2/src/routes/+page.svelte b/ui-v2/src/routes/+page.svelte new file mode 100644 index 0000000..4c2cc23 --- /dev/null +++ b/ui-v2/src/routes/+page.svelte @@ -0,0 +1,202 @@ + + + + libnovel + + + +
    +
    +

    {data.stats.totalBooks}

    +

    Books

    +
    +
    +

    {data.stats.totalChapters.toLocaleString()}

    +

    Chapters

    +
    +
    +

    {data.stats.booksInProgress}

    +

    In progress

    +
    +
    + + +{#if data.continueReading.length > 0} +
    +
    +

    Continue Reading

    + View all +
    + +
    +{/if} + + +{#if data.recentlyUpdated.length > 0} +
    +
    +

    Recently Updated

    + View all +
    + +
    +{/if} + + +{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0} +
    +

    Your library is empty

    +

    Discover novels and scrape them into your library.

    + + Discover Novels + +
    +{/if} + + +{#if data.subscriptionFeed.length > 0} +
    +
    +

    From People You Follow

    +
    + +
    +{/if} diff --git a/ui-v2/src/routes/admin/audio-jobs/+page.server.ts b/ui-v2/src/routes/admin/audio-jobs/+page.server.ts new file mode 100644 index 0000000..83baa67 --- /dev/null +++ b/ui-v2/src/routes/admin/audio-jobs/+page.server.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { listAudioJobs } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user?.role !== 'admin') { + redirect(302, '/'); + } + + const jobs = await listAudioJobs().catch((e) => { + log.warn('admin/audio-jobs', 'failed to load audio jobs', { err: String(e) }); + return []; + }); + + return { jobs }; +}; diff --git a/ui-v2/src/routes/admin/audio-jobs/+page.svelte b/ui-v2/src/routes/admin/audio-jobs/+page.svelte new file mode 100644 index 0000000..53591bc --- /dev/null +++ b/ui-v2/src/routes/admin/audio-jobs/+page.svelte @@ -0,0 +1,153 @@ + + + + Audio jobs — libnovel admin + + +
    +
    +
    +

    Audio jobs

    +

    + {stats.total} total · + {stats.done} done · + {#if stats.failed > 0} + {stats.failed} failed · + {/if} + {#if stats.inFlight > 0} + {stats.inFlight} in-flight + {:else} + 0 in-flight + {/if} +

    +
    +
    + + + + + {#if filtered.length === 0} +

    + {q.trim() ? 'No results.' : 'No audio jobs yet.'} +

    + {:else} +
    + + + + + + + + + + + + + {#each filtered as job} + + + + + + + + + {#if job.error_message} + + + + {/if} + {/each} + +
    BookCh.VoiceStatusStartedDuration
    + + {job.slug} + + {job.chapter}{job.voice} + {job.status} + {fmtDate(job.started)}{duration(job.started, job.finished)}
    {job.error_message}
    +
    + {/if} +
    diff --git a/ui-v2/src/routes/admin/audio/+page.server.ts b/ui-v2/src/routes/admin/audio/+page.server.ts new file mode 100644 index 0000000..9d18725 --- /dev/null +++ b/ui-v2/src/routes/admin/audio/+page.server.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { listAudioCache } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user?.role !== 'admin') { + redirect(302, '/'); + } + + const entries = await listAudioCache().catch((e) => { + log.warn('admin/audio', 'failed to load audio cache', { err: String(e) }); + return []; + }); + + return { entries }; +}; diff --git a/ui-v2/src/routes/admin/audio/+page.svelte b/ui-v2/src/routes/admin/audio/+page.svelte new file mode 100644 index 0000000..e3a8a91 --- /dev/null +++ b/ui-v2/src/routes/admin/audio/+page.svelte @@ -0,0 +1,93 @@ + + + + Audio cache — libnovel admin + + +
    +
    +

    Audio cache

    +

    {entries.length} cached audio file{entries.length !== 1 ? 's' : ''}

    +
    + + + + + {#if filtered.length === 0} +

    + {q.trim() ? 'No results.' : 'Audio cache is empty.'} +

    + {:else} +
    + + + + + + + + + + + + {#each filtered as entry} + {@const parts = parseKey(entry.cache_key)} + + + + + + + + {/each} + +
    BookChapterVoiceFilenameUpdated
    + + {parts.slug} + + {parts.chapter}{parts.voice} + {entry.filename} + {fmtDate(entry.updated)}
    +
    + {/if} +
    diff --git a/ui-v2/src/routes/admin/scrape/+page.server.ts b/ui-v2/src/routes/admin/scrape/+page.server.ts new file mode 100644 index 0000000..322646a --- /dev/null +++ b/ui-v2/src/routes/admin/scrape/+page.server.ts @@ -0,0 +1,29 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { listScrapingTasks } from '$lib/server/pocketbase'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user?.role !== 'admin') { + redirect(302, '/'); + } + + const [tasks, statusRes] = await Promise.all([ + listScrapingTasks().catch((e) => { + log.warn('admin/scrape', 'failed to load tasks', { err: String(e) }); + return []; + }), + fetch(`${SCRAPER_URL}/api/scrape/status`).catch(() => null) + ]); + + let running = false; + if (statusRes?.ok) { + const body = await statusRes.json().catch(() => null); + running = body?.running ?? false; + } + + return { tasks, running }; +}; diff --git a/ui-v2/src/routes/admin/scrape/+page.svelte b/ui-v2/src/routes/admin/scrape/+page.svelte new file mode 100644 index 0000000..dd3f185 --- /dev/null +++ b/ui-v2/src/routes/admin/scrape/+page.svelte @@ -0,0 +1,238 @@ + + + + Scrape tasks — libnovel admin + + +
    +
    +
    +

    Scrape tasks

    +

    + Job status: + {#if running} + Running + {:else} + Idle + {/if} +

    +
    + + +
    + +
    +
    + + +
    +

    Scrape a single book

    +
    + + +
    + {#if scrapeError} +

    {scrapeError}

    + {/if} +
    + + + {#if tasks.length === 0} +

    No scrape tasks yet.

    + {:else} +
    + + + + + + + + + + + + + + + + {#each tasks as task} + + + + + + + + + + + + {#if task.error_message} + + + + {/if} + {/each} + +
    KindStatusBooksChaptersSkippedErrorsStartedDurationActions
    + {task.kind} + {#if task.target_url} +
    + + {task.target_url.replace('https://novelfire.net/book/', '')} + + {/if} +
    + {task.status} + {task.books_found ?? 0}{task.chapters_scraped ?? 0}{task.chapters_skipped ?? 0}{task.errors ?? 0}{fmtDate(task.started)}{duration(task.started, task.finished)} + {#if task.status === 'pending'} + + {#if cancelErrors[task.id]} +

    {cancelErrors[task.id]}

    + {/if} + {/if} +
    {task.error_message}
    +
    + {/if} +
    diff --git a/ui-v2/src/routes/api/admin/scrape/+server.ts b/ui-v2/src/routes/api/admin/scrape/+server.ts new file mode 100644 index 0000000..a21b5f3 --- /dev/null +++ b/ui-v2/src/routes/api/admin/scrape/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/admin/scrape/status + * Admin-only proxy to the Go scraper's /api/scrape/status endpoint. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + try { + const res = await fetch(`${SCRAPER_URL}/api/scrape/status`); + if (!res.ok) return json({ running: false }); + const data = await res.json(); + return json({ running: data.running ?? false }); + } catch { + return json({ running: false }); + } +}; diff --git a/ui-v2/src/routes/api/audio/[slug]/[n]/+server.ts b/ui-v2/src/routes/api/audio/[slug]/[n]/+server.ts new file mode 100644 index 0000000..0ef37bf --- /dev/null +++ b/ui-v2/src/routes/api/audio/[slug]/[n]/+server.ts @@ -0,0 +1,100 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * POST /api/audio/[slug]/[n] + * Proxies the audio generation request to the scraper's /api/audio endpoint. + * Keeps the scraper URL server-side — the browser never needs to know it. + * + * Body: { voice?: string } + * + * Responses: + * 200 { status: "done" } — audio already cached; client should call + * GET /api/presign/audio to obtain a direct MinIO presigned URL. + * 202 { task_id: string, status: "pending"|"generating" } — generation + * enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done. + */ +export const POST: RequestHandler = async ({ params, request }) => { + const { slug, n } = params; + const chapter = parseInt(n, 10); + if (!slug || !chapter || chapter < 1) { + error(400, 'Invalid slug or chapter number'); + } + + let body: { voice?: string } = {}; + try { + body = await request.json(); + } catch { + // empty body is fine — scraper will use defaults + } + + const scraperRes = await fetch(`${SCRAPER_URL}/api/audio/${slug}/${chapter}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!scraperRes.ok) { + const text = await scraperRes.text().catch(() => ''); + log.error('audio', 'scraper audio generation failed', { slug, chapter, status: scraperRes.status, body: text }); + error(scraperRes.status as Parameters[0], text || 'Audio generation failed'); + } + + const data = (await scraperRes.json()) as + | { url: string; status: 'done' } + | { task_id: string; status: string }; + + // 202 Accepted: generation enqueued — return task_id + status for polling. + if (scraperRes.status === 202 || 'task_id' in data) { + return new Response(JSON.stringify(data), { + status: 202, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 200: audio was already cached. + // Return status only — no url — so the client calls GET /api/presign/audio + // and streams directly from MinIO instead of through the Node.js server. + return new Response( + JSON.stringify({ status: 'done' }), + { headers: { 'Content-Type': 'application/json' } } + ); +}; + +/** + * GET /api/audio/[slug]/[n]?voice=... + * Proxies the audio stream from the scraper's /api/audio-proxy endpoint. + * Kept as a fallback but no longer used as the primary playback path — + * AudioPlayer fetches a presigned MinIO URL directly via /api/presign/audio. + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { slug, n } = params; + const chapter = parseInt(n, 10); + if (!slug || !chapter || chapter < 1) { + error(400, 'Invalid slug or chapter number'); + } + + const voice = url.searchParams.get('voice') ?? ''; + const qs = new URLSearchParams(); + if (voice) qs.set('voice', voice); + + const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`); + + if (!scraperRes.ok) { + log.error('audio', 'scraper audio proxy failed', { slug, chapter, status: scraperRes.status }); + error(scraperRes.status as Parameters[0], 'Audio not found'); + } + + // Stream the audio body through — preserve Content-Type and Content-Length. + const headers = new Headers(); + headers.set('Content-Type', scraperRes.headers.get('Content-Type') ?? 'audio/mpeg'); + headers.set('Cache-Control', 'public, max-age=3600'); + const cl = scraperRes.headers.get('Content-Length'); + if (cl) headers.set('Content-Length', cl); + + return new Response(scraperRes.body, { headers }); +}; diff --git a/ui-v2/src/routes/api/audio/status/[slug]/[n]/+server.ts b/ui-v2/src/routes/api/audio/status/[slug]/[n]/+server.ts new file mode 100644 index 0000000..14d06ce --- /dev/null +++ b/ui-v2/src/routes/api/audio/status/[slug]/[n]/+server.ts @@ -0,0 +1,66 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/audio/status/[slug]/[n]?voice=... + * Proxies the audio generation status check to the scraper's + * GET /api/audio/status/{slug}/{n} endpoint. + * + * Possible responses passed through to the client: + * {"status":"done"} — audio ready; no url + * {"status":"pending"|"generating","task_id":"..."} — in progress + * {"status":"idle"} — no job yet + * {"status":"failed","error":"..."} — last job failed + * + * When status is "done" the scraper's internal proxy URL is stripped — the + * client must call GET /api/presign/audio to obtain a direct MinIO presigned + * URL. This avoids streaming audio through the Node.js server. + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { slug, n } = params; + const chapter = parseInt(n, 10); + if (!slug || !chapter || chapter < 1) { + error(400, 'Invalid slug or chapter number'); + } + + const voice = url.searchParams.get('voice') ?? ''; + const qs = new URLSearchParams(); + if (voice) qs.set('voice', voice); + + const scraperRes = await fetch( + `${SCRAPER_URL}/api/audio/status/${slug}/${chapter}?${qs.toString()}` + ); + + if (!scraperRes.ok) { + const text = await scraperRes.text().catch(() => ''); + log.error('audio', 'scraper audio status check failed', { + slug, + chapter, + status: scraperRes.status, + body: text + }); + error(scraperRes.status as Parameters[0], text || 'Status check failed'); + } + + const data = (await scraperRes.json()) as { + status: string; + task_id?: string; + url?: string; + error?: string; + }; + + // Strip the scraper's internal proxy URL from "done" responses. + // The client will call GET /api/presign/audio to get a direct MinIO URL, + // avoiding streaming audio through the Node.js server. + if (data.status === 'done') { + delete data.url; + } + + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/ui-v2/src/routes/api/audio/voice-samples/+server.ts b/ui-v2/src/routes/api/audio/voice-samples/+server.ts new file mode 100644 index 0000000..000f45c --- /dev/null +++ b/ui-v2/src/routes/api/audio/voice-samples/+server.ts @@ -0,0 +1,11 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +/** + * POST /api/audio/voice-samples + * The new backend does not expose a voice-samples generation endpoint. + * Return 501 so callers get a clear signal rather than a 502 proxy error. + */ +export const POST: RequestHandler = async () => { + return json({ error: 'Voice sample pre-generation is not supported by this backend.' }, { status: 501 }); +}; diff --git a/ui-v2/src/routes/api/auth/change-password/+server.ts b/ui-v2/src/routes/api/auth/change-password/+server.ts new file mode 100644 index 0000000..dba2d4e --- /dev/null +++ b/ui-v2/src/routes/api/auth/change-password/+server.ts @@ -0,0 +1,47 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { changePassword } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/auth/change-password + * Body: { currentPassword: string, newPassword: string } + * Requires authentication. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) { + error(401, 'Not authenticated'); + } + + let body: { currentPassword?: string; newPassword?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const currentPassword = body.currentPassword ?? ''; + const newPassword = body.newPassword ?? ''; + + if (!currentPassword || !newPassword) { + error(400, 'currentPassword and newPassword are required'); + } + + if (newPassword.length < 4) { + error(400, 'New password must be at least 4 characters'); + } + + try { + const ok = await changePassword(locals.user.id, currentPassword, newPassword); + if (!ok) { + error(401, 'Current password is incorrect'); + } + } catch (e: unknown) { + // Re-throw SvelteKit errors as-is + if (e && typeof e === 'object' && 'status' in e) throw e; + log.error('api/auth/change-password', 'unexpected error', { err: String(e) }); + error(500, 'An error occurred. Please try again.'); + } + + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/auth/login/+server.ts b/ui-v2/src/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..5d04a36 --- /dev/null +++ b/ui-v2/src/routes/api/auth/login/+server.ts @@ -0,0 +1,75 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; +import { createAuthToken } from '../../../../hooks.server'; +import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +/** + * POST /api/auth/login + * Body: { username: string, password: string } + * Returns: { token: string, user: { id, username, role } } + * + * Sets the libnovel_auth cookie and returns the raw token value so the + * iOS app can persist it for subsequent requests. + */ +export const POST: RequestHandler = async ({ request, cookies, locals }) => { + let body: { username?: string; password?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const username = (body.username ?? '').trim(); + const password = body.password ?? ''; + + if (!username || !password) { + error(400, 'Username and password are required'); + } + + let user; + try { + user = await loginUser(username, password); + } catch (e) { + log.error('api/auth/login', 'unexpected error', { username, err: String(e) }); + error(500, 'An error occurred. Please try again.'); + } + + if (!user) { + error(401, 'Invalid username or password'); + } + + // Merge anonymous session progress (non-fatal) + mergeSessionProgress(locals.sessionId, user.id).catch((e) => + log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) }) + ); + + const authSessionId = randomBytes(16).toString('hex'); + + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => + log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + return json({ + token, + user: { id: user.id, username: user.username, role: user.role ?? 'user' } + }); +}; diff --git a/ui-v2/src/routes/api/auth/logout/+server.ts b/ui-v2/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..9321e34 --- /dev/null +++ b/ui-v2/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const AUTH_COOKIE = 'libnovel_auth'; + +/** + * POST /api/auth/logout + * Clears the auth cookie and returns { ok: true }. + * Does not revoke the session record from PocketBase — + * for full revocation use DELETE /api/sessions/[id] first. + */ +export const POST: RequestHandler = async ({ cookies }) => { + cookies.delete(AUTH_COOKIE, { path: '/' }); + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/auth/me/+server.ts b/ui-v2/src/routes/api/auth/me/+server.ts new file mode 100644 index 0000000..7ac173d --- /dev/null +++ b/ui-v2/src/routes/api/auth/me/+server.ts @@ -0,0 +1,22 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getUserByUsername } from '$lib/server/pocketbase'; + +/** + * GET /api/auth/me + * Returns the currently authenticated user from the request's auth cookie. + * Returns 401 if not authenticated. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + error(401, 'Not authenticated'); + } + // Fetch full record from PocketBase to get avatar_url + const record = await getUserByUsername(locals.user.username).catch(() => null); + return json({ + id: locals.user.id, + username: locals.user.username, + role: locals.user.role, + avatar_url: record?.avatar_url ?? null + }); +}; diff --git a/ui-v2/src/routes/api/auth/register/+server.ts b/ui-v2/src/routes/api/auth/register/+server.ts new file mode 100644 index 0000000..58d0be7 --- /dev/null +++ b/ui-v2/src/routes/api/auth/register/+server.ts @@ -0,0 +1,84 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; +import { createAuthToken } from '../../../../hooks.server'; +import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +/** + * POST /api/auth/register + * Body: { username: string, password: string } + * Returns: { token: string, user: { id, username, role } } + * + * Sets the libnovel_auth cookie and returns the raw token value so the + * iOS app can persist it for subsequent requests. + */ +export const POST: RequestHandler = async ({ request, cookies, locals }) => { + let body: { username?: string; password?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const username = (body.username ?? '').trim(); + const password = body.password ?? ''; + + if (!username || !password) { + error(400, 'Username and password are required'); + } + if (username.length < 3 || username.length > 32) { + error(400, 'Username must be between 3 and 32 characters'); + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + error(400, 'Username may only contain letters, numbers, underscores and hyphens'); + } + if (password.length < 8) { + error(400, 'Password must be at least 8 characters'); + } + + let user; + try { + user = await createUser(username, password); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Registration failed.'; + if (msg.includes('Username already taken')) { + error(409, 'That username is already taken'); + } + log.error('api/auth/register', 'unexpected error', { username, err: String(e) }); + error(500, 'An error occurred. Please try again.'); + } + + // Merge anonymous session progress (non-fatal) + mergeSessionProgress(locals.sessionId, user.id).catch((e) => + log.warn('api/auth/register', 'mergeSessionProgress failed (non-fatal)', { err: String(e) }) + ); + + const authSessionId = randomBytes(16).toString('hex'); + + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((e) => + log.warn('api/auth/register', 'createUserSession failed (non-fatal)', { err: String(e) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + return json({ + token, + user: { id: user.id, username: user.username, role: user.role ?? 'user' } + }); +}; diff --git a/ui-v2/src/routes/api/book/[slug]/+server.ts b/ui-v2/src/routes/api/book/[slug]/+server.ts new file mode 100644 index 0000000..17dd8e3 --- /dev/null +++ b/ui-v2/src/routes/api/book/[slug]/+server.ts @@ -0,0 +1,111 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/book/[slug] + * Returns book metadata, chapter list, progress, and library status. + * + * If the book is not yet in PocketBase, asks the backend to enqueue a scrape + * task and returns 202 with { scraping: true, task_id }. + * The client should poll and retry once the task completes. + */ +export const GET: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + + // Try PocketBase first + let book = await getBook(slug).catch((e) => { + log.error('api/book', 'getBook failed', { slug, err: String(e) }); + return null; + }); + + if (book) { + let chapters, progress, saved; + try { + [chapters, progress, saved] = await Promise.all([ + listChapterIdx(slug), + getProgress(locals.sessionId, slug, locals.user?.id), + isBookSaved(locals.sessionId, slug, locals.user?.id) + ]); + } catch (e) { + log.error('api/book', 'failed to load book detail data', { slug, err: String(e) }); + error(500, 'Failed to load book'); + } + + return json({ + book, + chapters, + in_lib: true, + saved, + last_chapter: progress?.chapter ?? null, + scraping: false, + task_id: null + }); + } + + // Fall back to backend: enqueue scrape task if not in library. + try { + const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`); + + if (res.status === 202) { + const body: { task_id: string; message: string } = await res.json(); + log.info('api/book', 'scrape task enqueued', { slug, task_id: body.task_id }); + return json({ scraping: true, task_id: body.task_id, in_lib: false }, { status: 202 }); + } + + if (!res.ok) { + log.warn('api/book', 'book-preview returned error', { slug, status: res.status }); + error(404, `Book "${slug}" not found`); + } + + // 200 — book was already in library + const preview: { + in_lib: boolean; + meta: { + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[]; + summary: string; + total_chapters: number; + source_url: string; + }; + chapters: { number: number; title: string; date?: string }[]; + } = await res.json(); + + const previewBook = { + id: '', + slug: preview.meta.slug || slug, + title: preview.meta.title, + author: preview.meta.author, + cover: preview.meta.cover, + status: preview.meta.status, + genres: preview.meta.genres ?? [], + summary: preview.meta.summary, + total_chapters: preview.meta.total_chapters, + source_url: preview.meta.source_url, + ranking: 0, + meta_updated: '' + }; + + return json({ + book: previewBook, + chapters: preview.chapters, + in_lib: true, + saved: false, + last_chapter: null, + scraping: false, + task_id: null + }); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/book', 'book-preview fetch failed', { slug, err: String(e) }); + error(404, `Book "${slug}" not found`); + } +}; diff --git a/ui-v2/src/routes/api/browse-page/+server.ts b/ui-v2/src/routes/api/browse-page/+server.ts new file mode 100644 index 0000000..b22ea49 --- /dev/null +++ b/ui-v2/src/routes/api/browse-page/+server.ts @@ -0,0 +1,37 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/browse-page?page=2&genre=all&sort=popular&status=all + * + * Thin proxy to the Go scraper's /api/browse endpoint. + * Used by the infinite-scroll browse page to append subsequent pages + * without a full SSR navigation. + */ +export const GET: RequestHandler = async ({ url }) => { + const page = url.searchParams.get('page') ?? '1'; + const genre = url.searchParams.get('genre') ?? 'all'; + const sort = url.searchParams.get('sort') ?? 'popular'; + const status = url.searchParams.get('status') ?? 'all'; + + const params = new URLSearchParams({ page, genre, sort, status }); + const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`; + + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('browse-page', 'scraper returned error', { status: res.status }); + throw error(502, `Browse fetch failed: ${res.status}`); + } + const data = await res.json(); + return json(data); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('browse-page', 'network error', { err: String(e) }); + throw error(502, 'Could not reach browse service'); + } +}; diff --git a/ui-v2/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts b/ui-v2/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts new file mode 100644 index 0000000..6b46c9d --- /dev/null +++ b/ui-v2/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts @@ -0,0 +1,46 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/chapter-text-preview/[slug]/[n] + * Proxies to the scraper's /api/chapter-text-preview endpoint. + * Used client-side when the normal chapter path returns no content + * (chapter indexed but not yet scraped to MinIO). + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { slug, n } = params; + const chapter = parseInt(n, 10); + if (!slug || !chapter || chapter < 1) { + error(400, 'Invalid slug or chapter number'); + } + + // Forward optional query params (chapter_url, title) if present + const qs = new URLSearchParams(); + const chapterUrl = url.searchParams.get('chapter_url'); + const title = url.searchParams.get('title'); + if (chapterUrl) qs.set('chapter_url', chapterUrl); + if (title) qs.set('title', title); + + const scraperRes = await fetch( + `${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}` + ).catch((e) => { + log.error('chapter-preview', 'scraper fetch failed', { slug, chapter, err: String(e) }); + return null; + }); + + if (!scraperRes || !scraperRes.ok) { + const status = scraperRes?.status ?? 502; + log.error('chapter-preview', 'scraper returned error', { slug, chapter, status }); + error(status as Parameters[0], 'Chapter preview not available'); + } + + const data = await scraperRes.json(); + + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/ui-v2/src/routes/api/chapter/[slug]/[n]/+server.ts b/ui-v2/src/routes/api/chapter/[slug]/[n]/+server.ts new file mode 100644 index 0000000..7ee5784 --- /dev/null +++ b/ui-v2/src/routes/api/chapter/[slug]/[n]/+server.ts @@ -0,0 +1,128 @@ +import { json, error } from '@sveltejs/kit'; +import { marked } from 'marked'; +import type { RequestHandler } from './$types'; +import { getBook, listChapterIdx } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/chapter/[slug]/[n] + * Returns rendered chapter HTML, navigation info, and voice list. + * Supports ?preview=1&chapter_url=...&title=... for un-scraped books. + * + * Response shape mirrors ChapterResponse in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + const { slug } = params; + const n = parseInt(params.n, 10); + + if (!n || n < 1) error(400, 'Invalid chapter number'); + + const isPreview = url.searchParams.get('preview') === '1'; + const chapterUrl = url.searchParams.get('chapter_url') ?? ''; + const chapterTitle = url.searchParams.get('title') ?? ''; + + if (isPreview) { + // Preview path: scrape live, nothing from PocketBase/MinIO + const previewParams = new URLSearchParams(); + if (chapterUrl) previewParams.set('chapter_url', chapterUrl); + if (chapterTitle) previewParams.set('title', chapterTitle); + + let chapterData: { slug: string; number: number; title: string; text: string; url: string }; + try { + const res = await fetch( + `${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}` + ); + if (!res.ok) { + log.error('api/chapter', 'chapter-text-preview returned error', { slug, n, status: res.status }); + error(404, `Chapter ${n} not found`); + } + chapterData = await res.json(); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) }); + error(502, 'Could not fetch chapter preview'); + } + + const html = chapterData.text + ? '

    ' + chapterData.text.replace(/\n{2,}/g, '

    ').replace(/\n/g, '
    ') + '

    ' + : ''; + + let voices: string[] = []; + try { + const vRes = await fetch(`${SCRAPER_URL}/api/voices`); + if (vRes.ok) { + const d = (await vRes.json()) as { voices: string[] }; + voices = d.voices ?? []; + } + } catch { + // Non-critical + } + + const pb = await getBook(slug).catch(() => null); + + return json({ + book: { slug, title: pb?.title ?? slug, cover: pb?.cover ?? '' }, + chapter: { id: '', slug, number: n, title: chapterData.title || `Chapter ${n}`, date_label: '' }, + html, + voices, + prev: null, + next: null, + chapters: [], + is_preview: true + }); + } + + // Normal path: PocketBase + MinIO + const [book, chapters, voicesRes] = await Promise.all([ + getBook(slug), + listChapterIdx(slug), + fetch(`${SCRAPER_URL}/api/voices`).catch(() => null) + ]); + + if (!book) error(404, `Book "${slug}" not found`); + + const chapterIdx = chapters.find((c) => c.number === n); + if (!chapterIdx) error(404, `Chapter ${n} not found`); + + let voices: string[] = []; + try { + if (voicesRes?.ok) { + const data = (await voicesRes.json()) as { voices: string[] }; + voices = data.voices ?? []; + } + } catch { + // Non-critical + } + + let html = ''; + try { + const res = await fetch(`${SCRAPER_URL}/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`); + if (!res.ok) { + log.error('api/chapter', 'chapter-markdown returned error', { slug, n, status: res.status }); + error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content'); + } + const markdown = await res.text(); + html = marked(markdown) as string; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) }); + error(502, 'Could not fetch chapter content'); + } + + const prevChapter = chapters.find((c) => c.number === n - 1) ?? null; + const nextChapter = chapters.find((c) => c.number === n + 1) ?? null; + + return json({ + book: { slug: book.slug, title: book.title, cover: book.cover ?? '' }, + chapter: chapterIdx, + html, + voices, + prev: prevChapter ? prevChapter.number : null, + next: nextChapter ? nextChapter.number : null, + chapters: chapters.map((c) => ({ number: c.number, title: c.title })), + is_preview: false + }); +}; diff --git a/ui-v2/src/routes/api/comment/[id]/+server.ts b/ui-v2/src/routes/api/comment/[id]/+server.ts new file mode 100644 index 0000000..8bb998b --- /dev/null +++ b/ui-v2/src/routes/api/comment/[id]/+server.ts @@ -0,0 +1,26 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteComment } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * DELETE /api/comment/[id] + * Deletes a comment and its replies. Only the comment owner may delete. + * Requires authentication. + */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Login required'); + + const { id } = params; + + try { + await deleteComment(id, locals.user.id); + return new Response(null, { status: 204 }); + } catch (e) { + const msg = String(e); + if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment'); + if (msg.includes('not found')) error(404, 'Comment not found'); + log.error('api/comment/[id]', 'deleteComment failed', { id, err: msg }); + error(500, 'Failed to delete comment'); + } +}; diff --git a/ui-v2/src/routes/api/comment/[id]/vote/+server.ts b/ui-v2/src/routes/api/comment/[id]/vote/+server.ts new file mode 100644 index 0000000..5a7526e --- /dev/null +++ b/ui-v2/src/routes/api/comment/[id]/vote/+server.ts @@ -0,0 +1,33 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { voteComment } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/comment/[id]/vote + * Body: { vote: 'up' | 'down' } + * Casts, changes, or toggles off a vote on a comment. + * Works for both authenticated and anonymous users (session-scoped). + * Returns the updated comment. + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const { id } = params; + let body: { vote?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + if (body.vote !== 'up' && body.vote !== 'down') { + error(400, 'vote must be "up" or "down"'); + } + + try { + const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id); + return json(updated); + } catch (e) { + log.error('api/comment/[id]/vote', 'voteComment failed', { id, err: String(e) }); + error(500, 'Failed to record vote'); + } +}; diff --git a/ui-v2/src/routes/api/comments/[slug]/+server.ts b/ui-v2/src/routes/api/comments/[slug]/+server.ts new file mode 100644 index 0000000..b90e559 --- /dev/null +++ b/ui-v2/src/routes/api/comments/[slug]/+server.ts @@ -0,0 +1,102 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + listComments, + listReplies, + createComment, + getMyVotes, + type CommentSort +} from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/comments/[slug]?sort=new|top + * Returns top-level comments + their replies + current visitor's votes + avatar URLs. + * Response: { comments: BookComment[], myVotes: Record, avatarUrls: Record } + * Each top-level comment has a `replies` array attached. + */ +export const GET: RequestHandler = async ({ params, url, locals }) => { + const { slug } = params; + const sortParam = url.searchParams.get('sort') ?? 'new'; + const sort: CommentSort = sortParam === 'top' ? 'top' : 'new'; + + try { + const topLevel = await listComments(slug, sort); + + // Fetch replies for all top-level comments in parallel + const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id))); + const allReplies = repliesPerComment.flat(); + + // Build comment+reply list for vote lookup + const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)]; + const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id); + + // Attach replies to each top-level comment + const comments = topLevel.map((c, i) => ({ + ...c, + replies: repliesPerComment[i] + })); + + // Batch-resolve avatar presign URLs for all unique user_ids + const allComments = [...topLevel, ...allReplies]; + const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))]; + const avatarEntries = await Promise.all( + uniqueUserIds.map(async (userId) => { + try { + const url = await presignAvatarUrl(userId); + return [userId, url] as [string, string | null]; + } catch { + return [userId, null] as [string, null]; + } + }) + ); + const avatarUrls: Record = {}; + for (const [userId, url] of avatarEntries) { + if (url) avatarUrls[userId] = url; + } + + return json({ comments, myVotes, avatarUrls }); + } catch (e) { + log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) }); + error(500, 'Failed to load comments'); + } +}; + +/** + * POST /api/comments/[slug] + * Body: { body: string, parent_id?: string } + * Creates a new comment or reply. Requires authentication. + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + if (!locals.user) error(401, 'Login required to comment'); + + const { slug } = params; + let body: { body?: string; parent_id?: string }; + try { + body = await request.json(); + } catch { + error(400, 'Invalid JSON body'); + } + + const text = (body.body ?? '').trim(); + if (!text) error(400, 'Comment body is required'); + if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)'); + + // Enforce 1-level depth: parent_id must be a top-level comment + const parentId = body.parent_id?.trim() || undefined; + + try { + const comment = await createComment( + slug, + text, + locals.user.id, + locals.user.username, + parentId + ); + return json(comment, { status: 201 }); + } catch (e) { + log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) }); + error(500, 'Failed to post comment'); + } +}; diff --git a/ui-v2/src/routes/api/home/+server.ts b/ui-v2/src/routes/api/home/+server.ts new file mode 100644 index 0000000..62584cd --- /dev/null +++ b/ui-v2/src/routes/api/home/+server.ts @@ -0,0 +1,65 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + listBooks, + recentlyAddedBooks, + allProgress, + getHomeStats, + getSubscriptionFeed +} from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import type { Book, Progress } from '$lib/server/pocketbase'; + +/** + * GET /api/home + * Returns home screen data: continue-reading list, recently updated books, stats, + * and subscription feed (books recently read by followed users). + * Requires authentication (enforced by layout guard). + */ +export const GET: RequestHandler = async ({ locals }) => { + let allBooks: Book[] = []; + let recentBooks: Book[] = []; + let progressList: Progress[] = []; + let stats = { totalBooks: 0, totalChapters: 0 }; + + try { + [allBooks, recentBooks, progressList, stats] = await Promise.all([ + listBooks(), + recentlyAddedBooks(8), + allProgress(locals.sessionId, locals.user?.id), + getHomeStats() + ]); + } catch (e) { + log.error('api/home', 'failed to load home data', { err: String(e) }); + } + + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + + const continueReading = progressList + .filter((p) => bookMap.has(p.slug)) + .slice(0, 6) + .map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter })); + + const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); + const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); + + // Subscription feed — only available for logged-in users with following + let subscriptionFeed: Array<{ book: Book; readerUsername: string }> = []; + if (locals.user?.id) { + subscriptionFeed = await getSubscriptionFeed(locals.user.id).catch(() => []); + } + + return json({ + continue_reading: continueReading, + recently_updated: recentlyUpdated, + stats: { + totalBooks: stats.totalBooks, + totalChapters: stats.totalChapters, + booksInProgress: continueReading.length + }, + subscription_feed: subscriptionFeed.map((item) => ({ + book: item.book, + readerUsername: item.readerUsername + })) + }); +}; diff --git a/ui-v2/src/routes/api/library/+server.ts b/ui-v2/src/routes/api/library/+server.ts new file mode 100644 index 0000000..6f1c1eb --- /dev/null +++ b/ui-v2/src/routes/api/library/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/library + * Returns the user's library: books they have started reading or explicitly saved. + * Each item includes the book record, the last chapter read, and saved_at timestamp. + * + * Response shape mirrors LibraryItem in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ locals }) => { + let allBooks: Awaited>; + let progressList: Awaited>; + let savedSlugs: Set; + + try { + [allBooks, progressList, savedSlugs] = await Promise.all([ + listBooks(), + allProgress(locals.sessionId, locals.user?.id), + getSavedSlugs(locals.sessionId, locals.user?.id) + ]); + } catch (e) { + log.error('api/library', 'failed to load library data', { err: String(e) }); + allBooks = []; + progressList = []; + savedSlugs = new Set(); + } + + const progressMap: Record = {}; + const progressUpdatedMap: Record = {}; + for (const p of progressList) { + progressMap[p.slug] = p.chapter; + progressUpdatedMap[p.slug] = p.updated; + } + + const progressSlugs = new Set(progressList.map((p) => p.slug)); + const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug)); + + const withProgress = books.filter((b) => progressSlugs.has(b.slug)); + const savedOnly = books + .filter((b) => !progressSlugs.has(b.slug)) + .sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')); + + withProgress.sort((a, b) => { + const ta = progressUpdatedMap[a.slug] ?? ''; + const tb = progressUpdatedMap[b.slug] ?? ''; + return tb.localeCompare(ta); + }); + + const ordered = [...withProgress, ...savedOnly]; + + const items = ordered.map((book) => ({ + book, + last_chapter: progressMap[book.slug] ?? null, + saved_at: progressUpdatedMap[book.slug] ?? new Date().toISOString() + })); + + return json(items); +}; diff --git a/ui-v2/src/routes/api/library/[slug]/+server.ts b/ui-v2/src/routes/api/library/[slug]/+server.ts new file mode 100644 index 0000000..b0f9243 --- /dev/null +++ b/ui-v2/src/routes/api/library/[slug]/+server.ts @@ -0,0 +1,34 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { saveBook, unsaveBook } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/library/[slug] + * Save a book to the user's personal library. + */ +export const POST: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + try { + await saveBook(locals.sessionId, slug, locals.user?.id); + } catch (e) { + log.error('library', 'saveBook failed', { slug, err: String(e) }); + error(500, 'Failed to save book'); + } + return json({ ok: true }); +}; + +/** + * DELETE /api/library/[slug] + * Remove a book from the user's personal library. + */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + try { + await unsaveBook(locals.sessionId, slug, locals.user?.id); + } catch (e) { + log.error('library', 'unsaveBook failed', { slug, err: String(e) }); + error(500, 'Failed to remove book'); + } + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/presign/audio/+server.ts b/ui-v2/src/routes/api/presign/audio/+server.ts new file mode 100644 index 0000000..ed99ed0 --- /dev/null +++ b/ui-v2/src/routes/api/presign/audio/+server.ts @@ -0,0 +1,47 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { presignAudio } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; +import * as cache from '$lib/server/presignCache'; + +/** + * GET /api/presign/audio?slug=...&n=...&voice=... + * Returns a presigned MinIO URL for the audio file so the browser + * can stream it directly without going through the server. + * Returns 404 when the audio has not been generated yet. + * + * Results are cached in-process for 50 minutes (MinIO URLs are valid 1 hour) + * to avoid a backend + MinIO round-trip on every "Play" click. + */ +export const GET: RequestHandler = async ({ url }) => { + const slug = url.searchParams.get('slug'); + // Accept both 'n' (web) and 'chapter' (iOS) as the chapter number param + const n = parseInt(url.searchParams.get('n') ?? url.searchParams.get('chapter') ?? '', 10); + const voice = url.searchParams.get('voice') ?? ''; + + if (!slug || !n || n < 1) { + error(400, 'Missing slug or n'); + } + + const cacheKey = cache.audioKey(slug, n, voice); + + // Fast path: return cached URL if still valid. + const cached = cache.get(cacheKey); + if (cached) { + return json({ url: cached }); + } + + // Slow path: call backend → MinIO presign. + try { + const presignedUrl = await presignAudio(slug, n, voice || undefined); + cache.set(cacheKey, presignedUrl); + return json({ url: presignedUrl }); + } catch (e) { + const status = (e as { status?: number }).status; + if (status === 404) { + error(404, 'Audio not found'); + } + log.error('presign', 'presign audio failed', { slug, n, err: String(e) }); + error(500, `Could not get presigned URL: ${e}`); + } +}; diff --git a/ui-v2/src/routes/api/presign/voice-sample/+server.ts b/ui-v2/src/routes/api/presign/voice-sample/+server.ts new file mode 100644 index 0000000..0e0a6dc --- /dev/null +++ b/ui-v2/src/routes/api/presign/voice-sample/+server.ts @@ -0,0 +1,40 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { presignVoiceSample } from '$lib/server/minio'; +import * as cache from '$lib/server/presignCache'; + +/** + * GET /api/presign/voice-sample?voice=af_bella + * Returns a presigned URL for the voice sample audio file. + * Returns 404 if the sample has not been generated yet. + * + * Results are cached in-process for 50 minutes to avoid a backend + MinIO + * round-trip on every voice-selection preview play. + */ +export const GET: RequestHandler = async ({ url }) => { + const voice = url.searchParams.get('voice'); + if (!voice) { + error(400, 'Missing voice parameter'); + } + + const cacheKey = cache.sampleKey(voice); + + // Fast path: return cached URL if still valid. + const cached = cache.get(cacheKey); + if (cached) { + return json({ url: cached }); + } + + // Slow path: call backend → MinIO presign. + try { + const presignedUrl = await presignVoiceSample(voice); + cache.set(cacheKey, presignedUrl); + return json({ url: presignedUrl }); + } catch (e) { + const status = (e as { status?: number }).status; + if (status === 404) { + error(404, 'Voice sample not found'); + } + error(502, `Failed to presign voice sample: ${e}`); + } +}; diff --git a/ui-v2/src/routes/api/profile/avatar/+server.ts b/ui-v2/src/routes/api/profile/avatar/+server.ts new file mode 100644 index 0000000..4ca1afb --- /dev/null +++ b/ui-v2/src/routes/api/profile/avatar/+server.ts @@ -0,0 +1,81 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio'; +import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase'; + +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +/** + * POST /api/profile/avatar + * Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" } + * + * Returns a short-lived presigned PUT URL pointing at MinIO (public endpoint) + * so the client can upload the image bytes directly, bypassing the server. + * After the PUT completes, the client must call PATCH /api/profile/avatar + * with the returned key to record it in PocketBase. + * + * Returns: { upload_url: string, key: string } + */ +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + let mimeType = 'image/jpeg'; + try { + const body = await request.json(); + if (body?.mime_type) mimeType = body.mime_type; + } catch { + // default to jpeg if body is missing/invalid + } + + if (!ALLOWED_TYPES.includes(mimeType)) { + error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`); + } + + const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType); + return json({ upload_url: uploadUrl, key }); +}; + +/** + * PATCH /api/profile/avatar + * Body: JSON { key: string } + * + * Called after the client has successfully PUT the image to MinIO via the + * presigned URL. Records the object key in PocketBase and returns a fresh + * presigned GET URL for immediate display. + * + * Returns: { avatar_url: string | null } + */ +export const PATCH: RequestHandler = async ({ request, locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + let key: string | undefined; + try { + const body = await request.json(); + if (typeof body?.key === 'string') key = body.key; + } catch { + error(400, 'Invalid JSON body'); + } + + if (!key) error(400, 'Missing "key" field'); + + await updateUserAvatarUrl(locals.user.id, key); + + const avatarUrl = await presignAvatarUrl(locals.user.id); + return json({ avatar_url: avatarUrl }); +}; + +/** + * GET /api/profile/avatar + * Returns a presigned GET URL for the current user's avatar, or null if none set. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) error(401, 'Not authenticated'); + + const record = await getUserByUsername(locals.user.username).catch(() => null); + if (!record?.avatar_url) { + return json({ avatar_url: null }); + } + + const avatarUrl = await presignAvatarUrl(locals.user.id); + return json({ avatar_url: avatarUrl }); +}; diff --git a/ui-v2/src/routes/api/progress/+server.ts b/ui-v2/src/routes/api/progress/+server.ts new file mode 100644 index 0000000..98e3c59 --- /dev/null +++ b/ui-v2/src/routes/api/progress/+server.ts @@ -0,0 +1,27 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { setProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/progress + * Body: { slug: string, chapter: number } + * Records the user's reading position. + * When the user is logged in, progress is keyed by user_id so it syncs across devices. + * When anonymous, progress is keyed by the session cookie. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const body = await request.json().catch(() => null); + + if (!body || typeof body.slug !== 'string' || typeof body.chapter !== 'number') { + error(400, 'Invalid body — expected { slug, chapter }'); + } + + try { + await setProgress(locals.sessionId, body.slug, body.chapter, locals.user?.id); + } catch (e) { + log.error('progress', 'setProgress failed', { slug: body.slug, chapter: body.chapter, err: String(e) }); + error(500, 'Failed to save progress'); + } + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/progress/[slug]/+server.ts b/ui-v2/src/routes/api/progress/[slug]/+server.ts new file mode 100644 index 0000000..498703e --- /dev/null +++ b/ui-v2/src/routes/api/progress/[slug]/+server.ts @@ -0,0 +1,54 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { setProgress, deleteProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/progress/[slug] + * Body: { chapter: number } + * Records the user's reading position for a specific book. + * + * This is a slug-in-path variant of POST /api/progress (which takes slug in body). + * Used by the iOS app where slug is part of the URL path. + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const { slug } = params; + const body = await request.json().catch(() => null); + + if (!body || typeof body.chapter !== 'number') { + error(400, 'Invalid body — expected { chapter: number }'); + } + + try { + await setProgress(locals.sessionId, slug, body.chapter, locals.user?.id); + } catch (e) { + log.error('api/progress/[slug]', 'setProgress failed', { + slug, + chapter: body.chapter, + err: String(e) + }); + error(500, 'Failed to save progress'); + } + + return json({ ok: true }); +}; + +/** + * DELETE /api/progress/[slug] + * Removes reading progress for a specific book (removes from library/continue reading). + */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + const { slug } = params; + + try { + await deleteProgress(locals.sessionId, slug, locals.user?.id); + } catch (e) { + log.error('api/progress/[slug]', 'deleteProgress failed', { + slug, + err: String(e) + }); + error(500, 'Failed to delete progress'); + } + + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/progress/audio-time/+server.ts b/ui-v2/src/routes/api/progress/audio-time/+server.ts new file mode 100644 index 0000000..1659ba2 --- /dev/null +++ b/ui-v2/src/routes/api/progress/audio-time/+server.ts @@ -0,0 +1,56 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { setAudioTime, getAudioTime } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/progress/audio-time?slug=&chapter= + * Returns the last saved audio position for a chapter, or null. + */ +export const GET: RequestHandler = async ({ url, locals }) => { + const slug = url.searchParams.get('slug'); + const chapterParam = url.searchParams.get('chapter'); + + if (!slug || !chapterParam) { + error(400, 'Missing slug or chapter query params'); + } + + const chapter = parseInt(chapterParam, 10); + if (isNaN(chapter)) { + error(400, 'chapter must be a number'); + } + + try { + const audioTime = await getAudioTime(locals.sessionId, slug, chapter, locals.user?.id); + return json({ audioTime }); + } catch (e) { + log.error('audio-time', 'GET failed', { slug, chapter, err: String(e) }); + error(500, 'Failed to load audio time'); + } +}; + +/** + * PATCH /api/progress/audio-time + * Body: { slug: string, chapter: number, audioTime: number } + * Saves the current audio playback position. + */ +export const PATCH: RequestHandler = async ({ request, locals }) => { + const body = await request.json().catch(() => null); + + if ( + !body || + typeof body.slug !== 'string' || + typeof body.chapter !== 'number' || + typeof body.audioTime !== 'number' + ) { + error(400, 'Invalid body — expected { slug, chapter, audioTime }'); + } + + try { + await setAudioTime(locals.sessionId, body.slug, body.chapter, body.audioTime, locals.user?.id); + } catch (e) { + log.error('audio-time', 'PATCH failed', { slug: body.slug, chapter: body.chapter, err: String(e) }); + error(500, 'Failed to save audio time'); + } + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/ranking/+server.ts b/ui-v2/src/routes/api/ranking/+server.ts new file mode 100644 index 0000000..f18d2b1 --- /dev/null +++ b/ui-v2/src/routes/api/ranking/+server.ts @@ -0,0 +1,27 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/ranking + * Proxies to the Go scraper's /api/ranking endpoint. + * Returns the top-ranked novels list as JSON. + */ +export const GET: RequestHandler = async () => { + try { + const res = await fetch(`${SCRAPER_URL}/api/ranking`); + if (!res.ok) { + log.error('api/ranking', 'scraper returned error', { status: res.status }); + error(502, `Ranking fetch failed: ${res.status}`); + } + const data = await res.json(); + return json(data); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/ranking', 'network error', { err: String(e) }); + error(502, 'Could not load ranking'); + } +}; diff --git a/ui-v2/src/routes/api/scrape/+server.ts b/ui-v2/src/routes/api/scrape/+server.ts new file mode 100644 index 0000000..09327eb --- /dev/null +++ b/ui-v2/src/routes/api/scrape/+server.ts @@ -0,0 +1,64 @@ +/** + * POST /api/scrape + * + * Proxies scrape requests to the Go scraper backend. + * Admin-only — returns 403 if the authenticated user is not an admin. + * + * Request body (JSON): + * { "url": "https://novelfire.net/book/..." } — scrape a single book + * {} — scrape the full catalogue + * + * Responses mirror the Go scraper: + * 202 Accepted — job enqueued + * 409 Conflict — a scrape job is already running + * 400 Bad Request + * 403 Forbidden — not an admin + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export const POST: RequestHandler = async ({ request, locals }) => { + // Admin guard + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + let body: { url?: string } = {}; + try { + body = await request.json(); + } catch { + // empty body is fine — means "scrape all" + } + + // Decide which scraper endpoint to call + const isBookScrape = typeof body.url === 'string' && body.url.length > 0; + const endpoint = isBookScrape ? '/scrape/book' : '/scrape'; + + const upstream = `${SCRAPER_URL}${endpoint}`; + let res: Response; + try { + res = await fetch(upstream, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined + }); + } catch (e) { + log.error('scrape', 'scraper proxy network error', { endpoint, err: String(e) }); + throw error(502, 'Could not reach scraper'); + } + + if (!res.ok && res.status >= 500) { + const text = await res.text().catch(() => ''); + log.error('scrape', 'scraper returned error', { endpoint, status: res.status, body: text }); + } + + const data = await res.json().catch(() => ({})); + + // Pass through the status code from the Go scraper (202, 409, 400, …) + return json(data, { status: res.status }); +}; diff --git a/ui-v2/src/routes/api/scrape/cancel/[id]/+server.ts b/ui-v2/src/routes/api/scrape/cancel/[id]/+server.ts new file mode 100644 index 0000000..c231103 --- /dev/null +++ b/ui-v2/src/routes/api/scrape/cancel/[id]/+server.ts @@ -0,0 +1,42 @@ +/** + * POST /api/scrape/cancel/[id] + * + * Admin-only proxy that cancels a pending scrape (or audio) task by ID. + * Forwards the request to the Go backend POST /api/cancel-task/{id}. + * + * Responses: + * 200 OK — task cancelled + * 403 Forbidden — not an admin + * 409 Conflict — task cannot be cancelled (already running/done/not found) + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export const POST: RequestHandler = async ({ params, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const { id } = params; + if (!id) { + throw error(400, 'Missing task id'); + } + + let res: Response; + try { + res = await fetch(`${SCRAPER_URL}/api/cancel-task/${encodeURIComponent(id)}`, { + method: 'POST' + }); + } catch (e) { + log.error('scrape/cancel', 'network error cancelling task', { id, err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui-v2/src/routes/api/scrape/range/+server.ts b/ui-v2/src/routes/api/scrape/range/+server.ts new file mode 100644 index 0000000..c827dfb --- /dev/null +++ b/ui-v2/src/routes/api/scrape/range/+server.ts @@ -0,0 +1,62 @@ +/** + * POST /api/scrape/range + * + * Proxies range-scrape requests to the Go scraper backend at POST /scrape/book/range. + * Admin-only. + * + * Request body (JSON): + * { "url": "https://novelfire.net/book/...", "from": 50, "to": 100 } + * "to" is optional — omit to scrape from "from" to the end. + * + * Responses mirror the Go scraper: + * 202 Accepted — job enqueued + * 409 Conflict — a scrape job is already running + * 400 Bad Request + * 403 Forbidden — not an admin + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export const POST: RequestHandler = async ({ request, locals }) => { + // Admin guard + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + let body: { url?: string; from?: number; to?: number } = {}; + try { + body = await request.json(); + } catch { + throw error(400, 'Invalid JSON body'); + } + + if (!body.url || typeof body.from !== 'number') { + throw error(400, 'url and from are required'); + } + + const upstream = `${SCRAPER_URL}/scrape/book/range`; + let res: Response; + try { + res = await fetch(upstream, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: body.url, from: body.from, to: body.to }) + }); + } catch (e) { + log.error('scrape/range', 'scraper proxy network error', { err: String(e) }); + throw error(502, 'Could not reach scraper'); + } + + if (!res.ok && res.status >= 500) { + const text = await res.text().catch(() => ''); + log.error('scrape/range', 'scraper returned error', { status: res.status, body: text }); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +}; diff --git a/ui-v2/src/routes/api/search/+server.ts b/ui-v2/src/routes/api/search/+server.ts new file mode 100644 index 0000000..b802b2a --- /dev/null +++ b/ui-v2/src/routes/api/search/+server.ts @@ -0,0 +1,36 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/search?q= + * Proxies to the Go scraper's /api/search endpoint. + * Returns: { results, local_count, remote_count } + * + * Response shape mirrors SearchResponse in the iOS APIClient. + */ +export const GET: RequestHandler = async ({ url }) => { + const q = url.searchParams.get('q') ?? ''; + + if (q.trim().length < 2) { + return json({ results: [], local_count: 0, remote_count: 0 }); + } + + const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(q.trim())}`; + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('api/search', 'scraper returned error', { status: res.status, q }); + error(502, `Search failed: ${res.status}`); + } + const data = await res.json(); + return json(data); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('api/search', 'network error', { q, err: String(e) }); + error(502, 'Could not reach search service'); + } +}; diff --git a/ui-v2/src/routes/api/sessions/+server.ts b/ui-v2/src/routes/api/sessions/+server.ts new file mode 100644 index 0000000..5feb689 --- /dev/null +++ b/ui-v2/src/routes/api/sessions/+server.ts @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listUserSessions } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/sessions + * Returns all active sessions for the logged-in user. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + error(401, 'Not logged in'); + } + + try { + const sessions = await listUserSessions(locals.user.id); + // Don't expose raw session_id to the client — only the record ID for revocation + const safe = sessions.map((s) => ({ + id: s.id, + user_agent: s.user_agent, + ip: s.ip, + created_at: s.created_at, + last_seen: s.last_seen, + // Tell the client whether this is the currently active session + is_current: s.session_id === locals.user!.authSessionId + })); + return json({ sessions: safe }); + } catch (e) { + log.error('sessions', 'GET failed', { err: String(e) }); + error(500, 'Failed to load sessions'); + } +}; diff --git a/ui-v2/src/routes/api/sessions/[id]/+server.ts b/ui-v2/src/routes/api/sessions/[id]/+server.ts new file mode 100644 index 0000000..f40774a --- /dev/null +++ b/ui-v2/src/routes/api/sessions/[id]/+server.ts @@ -0,0 +1,41 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { revokeUserSession } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * DELETE /api/sessions/[id] + * Revokes a specific session by its PocketBase record ID. + * Only the owner can revoke their own sessions. + */ +export const DELETE: RequestHandler = async ({ params, locals, cookies }) => { + if (!locals.user) { + error(401, 'Not logged in'); + } + + const recordId = params.id; + if (!recordId) { + error(400, 'Session ID required'); + } + + try { + const ok = await revokeUserSession(recordId, locals.user.id); + if (!ok) { + error(404, 'Session not found or not yours'); + } + + // If the user is terminating their own current session, clear their auth cookie + // so they get logged out immediately (the hook would do this on the next request anyway, + // but clearing it here gives instant feedback for the "end this session" flow). + // For other sessions, we leave the cookie intact. + // We detect "current session" via authSessionId — but since the client sends the + // record ID (not the session_id), we rely on the UI to redirect after ending its own session. + + log.info('sessions', 'session revoked', { recordId, userId: locals.user.id }); + return json({ ok: true }); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; // re-throw SvelteKit errors + log.error('sessions', 'DELETE failed', { recordId, err: String(e) }); + error(500, 'Failed to revoke session'); + } +}; diff --git a/ui-v2/src/routes/api/settings/+server.ts b/ui-v2/src/routes/api/settings/+server.ts new file mode 100644 index 0000000..0e27ad4 --- /dev/null +++ b/ui-v2/src/routes/api/settings/+server.ts @@ -0,0 +1,49 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSettings, saveSettings } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/settings + * Returns the current user's settings (auto_next, voice, speed). + * Returns defaults if no settings record exists yet. + */ +export const GET: RequestHandler = async ({ locals }) => { + try { + const settings = await getSettings(locals.sessionId, locals.user?.id); + return json({ + autoNext: settings?.auto_next ?? false, + voice: settings?.voice ?? 'af_bella', + speed: settings?.speed ?? 1.0 + }); + } catch (e) { + log.error('settings', 'GET failed', { err: String(e) }); + error(500, 'Failed to load settings'); + } +}; + +/** + * PUT /api/settings + * Body: { autoNext: boolean, voice: string, speed: number } + * Saves user preferences. + */ +export const PUT: RequestHandler = async ({ request, locals }) => { + const body = await request.json().catch(() => null); + + if ( + !body || + typeof body.autoNext !== 'boolean' || + typeof body.voice !== 'string' || + typeof body.speed !== 'number' + ) { + error(400, 'Invalid body — expected { autoNext, voice, speed }'); + } + + try { + await saveSettings(locals.sessionId, body, locals.user?.id); + } catch (e) { + log.error('settings', 'PUT failed', { err: String(e) }); + error(500, 'Failed to save settings'); + } + return json({ ok: true }); +}; diff --git a/ui-v2/src/routes/api/users/[username]/+server.ts b/ui-v2/src/routes/api/users/[username]/+server.ts new file mode 100644 index 0000000..7c8f5b5 --- /dev/null +++ b/ui-v2/src/routes/api/users/[username]/+server.ts @@ -0,0 +1,46 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getPublicProfile, getSubscription } from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/users/[username] + * Returns public profile info + whether the current user is subscribed. + */ +export const GET: RequestHandler = async ({ params, locals }) => { + const { username } = params; + + try { + const profile = await getPublicProfile(username); + if (!profile) error(404, `User "${username}" not found`); + + // Resolve avatar presigned URL if set + let avatarUrl: string | null = null; + if (profile.avatar_url) { + avatarUrl = await presignAvatarUrl(profile.id).catch(() => null); + } + + // Is the current logged-in user subscribed? + let isSubscribed = false; + if (locals.user && locals.user.id !== profile.id) { + const sub = await getSubscription(locals.user.id, profile.id).catch(() => null); + isSubscribed = !!sub; + } + + return json({ + id: profile.id, + username: profile.username, + avatarUrl, + created: profile.created, + followerCount: profile.followerCount, + followingCount: profile.followingCount, + isSubscribed, + isSelf: locals.user?.id === profile.id + }); + } catch (e) { + if ((e as { status?: number }).status === 404) throw e; + log.error('api/users', 'failed to load profile', { username, err: String(e) }); + error(500, 'Failed to load profile'); + } +}; diff --git a/ui-v2/src/routes/api/users/[username]/library/+server.ts b/ui-v2/src/routes/api/users/[username]/library/+server.ts new file mode 100644 index 0000000..8fcb739 --- /dev/null +++ b/ui-v2/src/routes/api/users/[username]/library/+server.ts @@ -0,0 +1,43 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getUserByUsername, + getUserPublicLibrary, + getUserCurrentlyReading +} from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/users/[username]/library + * Returns the public library + currently-reading list for a user. + * Does not require authentication — all data is public. + */ +export const GET: RequestHandler = async ({ params }) => { + const { username } = params; + + const user = await getUserByUsername(username).catch(() => null); + if (!user) error(404, `User "${username}" not found`); + + try { + const [currentlyReading, library] = await Promise.all([ + getUserCurrentlyReading(user.id), + getUserPublicLibrary(user.id) + ]); + + return json({ + currently_reading: currentlyReading.map((item) => ({ + book: item.book, + last_chapter: item.chapter, + saved: false + })), + library: library.map((item) => ({ + book: item.book, + last_chapter: item.chapter, + saved: item.saved + })) + }); + } catch (e) { + log.error('api/users/library', 'failed to load library', { username, err: String(e) }); + error(500, 'Failed to load library'); + } +}; diff --git a/ui-v2/src/routes/api/users/[username]/subscribe/+server.ts b/ui-v2/src/routes/api/users/[username]/subscribe/+server.ts new file mode 100644 index 0000000..c381dfc --- /dev/null +++ b/ui-v2/src/routes/api/users/[username]/subscribe/+server.ts @@ -0,0 +1,48 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getUserByUsername, + subscribe, + unsubscribe, + getSubscription +} from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/users/[username]/subscribe — subscribe to a user + * DELETE /api/users/[username]/subscribe — unsubscribe + * Requires authentication. + */ +export const POST: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Login required'); + + const { username } = params; + const target = await getUserByUsername(username).catch(() => null); + if (!target) error(404, `User "${username}" not found`); + if (locals.user.id === target.id) error(400, 'Cannot subscribe to yourself'); + + try { + await subscribe(locals.user.id, target.id); + const sub = await getSubscription(locals.user.id, target.id); + return json({ subscribed: true, subId: sub?.id ?? null }); + } catch (e) { + log.error('api/users/subscribe', 'subscribe failed', { username, err: String(e) }); + error(500, 'Failed to subscribe'); + } +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Login required'); + + const { username } = params; + const target = await getUserByUsername(username).catch(() => null); + if (!target) error(404, `User "${username}" not found`); + + try { + await unsubscribe(locals.user.id, target.id); + return json({ subscribed: false }); + } catch (e) { + log.error('api/users/subscribe', 'unsubscribe failed', { username, err: String(e) }); + error(500, 'Failed to unsubscribe'); + } +}; diff --git a/ui-v2/src/routes/api/voices/+server.ts b/ui-v2/src/routes/api/voices/+server.ts new file mode 100644 index 0000000..d07d32c --- /dev/null +++ b/ui-v2/src/routes/api/voices/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/voices + * Proxies the voice list from the scraper → Kokoro. + * Returns { voices: string[] } + */ +export const GET: RequestHandler = async () => { + try { + const res = await fetch(`${SCRAPER_URL}/api/voices`); + if (!res.ok) { + return json({ voices: [] }); + } + const data = (await res.json()) as { voices: string[] }; + return json({ voices: data.voices ?? [] }); + } catch { + return json({ voices: [] }); + } +}; diff --git a/ui-v2/src/routes/books/+page.server.ts b/ui-v2/src/routes/books/+page.server.ts new file mode 100644 index 0000000..6d0ec72 --- /dev/null +++ b/ui-v2/src/routes/books/+page.server.ts @@ -0,0 +1,57 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ locals }) => { + let allBooks: Awaited>; + let progressList: Awaited>; + let savedSlugs: Set; + + try { + [allBooks, progressList, savedSlugs] = await Promise.all([ + listBooks(), + allProgress(locals.sessionId, locals.user?.id), + getSavedSlugs(locals.sessionId, locals.user?.id) + ]); + } catch (e) { + log.error('books', 'failed to load library data', { err: String(e) }); + allBooks = []; + progressList = []; + savedSlugs = new Set(); + } + + // Build a quick lookup: slug → last chapter read + const progressMap: Record = {}; + for (const p of progressList) { + progressMap[p.slug] = p.chapter; + } + + // Library = books the user has started reading OR explicitly saved + const progressSlugs = new Set(progressList.map((p) => p.slug)); + const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug)); + + // Sort: books with progress first (most-recently-read order is implicit via progressList), + // then saved-only books alphabetically. + const withProgress = books.filter((b) => progressSlugs.has(b.slug)); + const savedOnly = books + .filter((b) => !progressSlugs.has(b.slug)) + .sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')); + + // Re-sort withProgress by most recent progress update + const progressUpdatedMap: Record = {}; + for (const p of progressList) { + progressUpdatedMap[p.slug] = p.updated; + } + withProgress.sort((a, b) => { + const ta = progressUpdatedMap[a.slug] ?? ''; + const tb = progressUpdatedMap[b.slug] ?? ''; + return tb.localeCompare(ta); // descending — most recently read first + }); + + return { + books: [...withProgress, ...savedOnly], + progressMap, + savedSlugs: [...savedSlugs] + }; +}; diff --git a/ui-v2/src/routes/books/+page.svelte b/ui-v2/src/routes/books/+page.svelte new file mode 100644 index 0000000..c54affa --- /dev/null +++ b/ui-v2/src/routes/books/+page.svelte @@ -0,0 +1,99 @@ + + + + Library — libnovel + + +
    +

    Library

    +

    + {data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''} +

    +
    + +{#if !data.books?.length} +
    +

    Your library is empty.

    +

    + Books you start reading or save from + Discover + will appear here. +

    +
    +{:else} + +{/if} diff --git a/ui-v2/src/routes/books/[slug]/+page.server.ts b/ui-v2/src/routes/books/[slug]/+page.server.ts new file mode 100644 index 0000000..1ad8206 --- /dev/null +++ b/ui-v2/src/routes/books/[slug]/+page.server.ts @@ -0,0 +1,123 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const { slug } = params; + + // Try fetching from PocketBase first + let book = await getBook(slug).catch((e) => { + log.error('books', 'getBook failed', { slug, err: String(e) }); + return null; + }); + + if (book) { + // Book is in the library — normal path + let chapters, progress, saved; + try { + [chapters, progress, saved] = await Promise.all([ + listChapterIdx(slug), + getProgress(locals.sessionId, slug, locals.user?.id), + isBookSaved(locals.sessionId, slug, locals.user?.id) + ]); + } catch (e) { + log.error('books', 'failed to load book page data', { slug, err: String(e) }); + throw error(500, 'Failed to load book'); + } + + return { + book, + chapters, + inLib: true, + saved, + lastChapter: progress?.chapter ?? null, + isAdmin: locals.user?.role === 'admin', + isLoggedIn: !!locals.user, + currentUserId: locals.user?.id ?? '', + // Not scraping + scraping: false, + taskId: null as string | null + }; + } + + // Book not in PocketBase — ask backend to enqueue a scrape task. + try { + const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`); + + if (res.status === 202) { + // Scrape task enqueued — show "scraping" placeholder page. + const body: { task_id: string; message: string } = await res.json(); + log.info('books', 'scrape task enqueued for book', { slug, task_id: body.task_id }); + return { + book: null, + chapters: [], + inLib: false, + saved: false, + lastChapter: null, + isAdmin: locals.user?.role === 'admin', + isLoggedIn: !!locals.user, + currentUserId: locals.user?.id ?? '', + scraping: true, + taskId: body.task_id + }; + } + + if (!res.ok) { + log.warn('books', 'book-preview returned error', { slug, status: res.status }); + error(404, `Book "${slug}" not found`); + } + + // 200 — book was already in library when backend checked + const preview: { + in_lib: boolean; + meta: { + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[]; + summary: string; + total_chapters: number; + source_url: string; + }; + chapters: { number: number; title: string; date?: string }[]; + } = await res.json(); + + const previewBook = { + id: '', + slug: preview.meta.slug || slug, + title: preview.meta.title, + author: preview.meta.author, + cover: preview.meta.cover, + status: preview.meta.status, + genres: preview.meta.genres ?? [], + summary: preview.meta.summary, + total_chapters: preview.meta.total_chapters, + source_url: preview.meta.source_url, + ranking: 0, + meta_updated: '' + }; + + return { + book: previewBook, + chapters: preview.chapters, + inLib: true, + saved: false, + lastChapter: null, + isAdmin: locals.user?.role === 'admin', + isLoggedIn: !!locals.user, + currentUserId: locals.user?.id ?? '', + scraping: false, + taskId: null as string | null + }; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('books', 'book-preview fetch failed', { slug, err: String(e) }); + error(404, `Book "${slug}" not found`); + } +}; diff --git a/ui-v2/src/routes/books/[slug]/+page.svelte b/ui-v2/src/routes/books/[slug]/+page.svelte new file mode 100644 index 0000000..d95fd61 --- /dev/null +++ b/ui-v2/src/routes/books/[slug]/+page.svelte @@ -0,0 +1,420 @@ + + + + {data.scraping ? 'Scraping…' : data.book?.title ?? 'Book'} — libnovel + + +{#if data.scraping} + +
    + + + + +
    +

    Scraping in progress…

    +

    + Fetching the first 20 chapters. Refresh the page in a minute. +

    + {#if data.taskId} +

    task: {data.taskId}

    + {/if} +
    + ← Home +
    + +{:else} +{@const book = data.book!} + + +
    + + {#if book.cover} + + {/if} + + +
    + +
    + + {#if book.cover} + {book.title} + {/if} + + +
    + +
    +

    {book.title}

    + {#if !data.inLib} + + not in library + + {/if} +
    + + + {#if book.author} +

    {book.author}

    + {/if} + + +
    + {#if book.status} + {book.status} + {/if} + {#each genres as genre} + {genre} + {/each} +
    + + + {#if book.summary} +
    +

    + {book.summary} +

    + {#if book.summary.length > 220} + + {/if} +
    + {/if} + + + +
    +
    + + +
    + {#if data.lastChapter} + + Continue ch.{data.lastChapter} + + {/if} + {#if chapterList.length > 0} + + {data.inLib ? 'Start from ch.1' : 'Preview ch.1'} + + {/if} + {#if data.inLib} + + {/if} +
    +
    +
    + + +
    + + + + + +
    + Chapters + {#if chapterList.length > 0} + + {#if data.lastChapter && data.lastChapter > 0} + Reading ch.{data.lastChapter} of {chapterList.length} + {:else} + {chapterList.length} chapter{chapterList.length === 1 ? '' : 's'} + {/if} + + {/if} +
    + + + +
    + + + {#if data.isAdmin && book.source_url} +
    + + + {#if adminOpen} +
    + +
    + + {#if scrapeResult} + + {scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'} + + {/if} +
    + + +
    +
    + + +
    +
    + + +
    + + {#if rangeResult} + + {rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'} + + {/if} +
    +
    + {/if} +
    + {/if} +
    + + + + +{/if} diff --git a/ui-v2/src/routes/books/[slug]/chapters/+page.server.ts b/ui-v2/src/routes/books/[slug]/chapters/+page.server.ts new file mode 100644 index 0000000..9352791 --- /dev/null +++ b/ui-v2/src/routes/books/[slug]/chapters/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const { slug } = params; + + const book = await getBook(slug).catch((e) => { + log.error('chapters', 'getBook failed', { slug, err: String(e) }); + return null; + }); + + if (!book) error(404, `Book "${slug}" not found`); + + let chapters, progress; + try { + [chapters, progress] = await Promise.all([ + listChapterIdx(slug), + getProgress(locals.sessionId, slug, locals.user?.id) + ]); + } catch (e) { + log.error('chapters', 'failed to load chapters', { slug, err: String(e) }); + throw error(500, 'Failed to load chapters'); + } + + return { + book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters }, + chapters, + lastChapter: progress?.chapter ?? null + }; +}; diff --git a/ui-v2/src/routes/books/[slug]/chapters/+page.svelte b/ui-v2/src/routes/books/[slug]/chapters/+page.svelte new file mode 100644 index 0000000..89a8ed9 --- /dev/null +++ b/ui-v2/src/routes/books/[slug]/chapters/+page.svelte @@ -0,0 +1,203 @@ + + + + {data.book.title} — Chapters — libnovel + + + +
    + + + + + Back + + / +

    {data.book.title}

    +
    + + +
    + + + + + {#if searchQuery} + + {/if} +
    + + +{#if !searchQuery && totalGroups > 1} +
    + {#each Array(totalGroups) as _, i} + + {/each} +
    +{/if} + + +{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup} + +{/if} + + +{#if visibleChapters.length === 0} + {#if searchQuery} +

    No chapters match "{searchQuery}"

    + {:else} +

    No chapters available yet.

    + {/if} +{:else} + + {#if searchQuery} +

    {visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}

    + {/if} + + + + + {#if !searchQuery && totalGroups > 1} +
    + {#each Array(totalGroups) as _, i} + + {/each} +
    + {/if} +{/if} diff --git a/ui-v2/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui-v2/src/routes/books/[slug]/chapters/[n]/+page.server.ts new file mode 100644 index 0000000..c7b8b45 --- /dev/null +++ b/ui-v2/src/routes/books/[slug]/chapters/[n]/+page.server.ts @@ -0,0 +1,140 @@ +import { error } from '@sveltejs/kit'; +import { marked } from 'marked'; +import type { PageServerLoad } from './$types'; +import { getBook, listChapterIdx } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export const load: PageServerLoad = async ({ params, url, locals }) => { + const { slug } = params; + const n = parseInt(params.n, 10); + + if (!n || n < 1) error(400, 'Invalid chapter number'); + + const isPreview = url.searchParams.get('preview') === '1'; + const chapterUrl = url.searchParams.get('chapter_url') ?? ''; + const chapterTitle = url.searchParams.get('title') ?? ''; + + if (isPreview) { + // ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ── + const previewParams = new URLSearchParams(); + if (chapterUrl) previewParams.set('chapter_url', chapterUrl); + if (chapterTitle) previewParams.set('title', chapterTitle); + + let chapterData: { slug: string; number: number; title: string; text: string; url: string }; + try { + const res = await fetch( + `${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}` + ); + if (!res.ok) { + log.error('chapter', 'chapter-text-preview returned error', { slug, n, status: res.status }); + error(404, `Chapter ${n} not found`); + } + chapterData = await res.json(); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) }); + error(502, 'Could not fetch chapter preview'); + } + + // Wrap plain text in minimal HTML paragraphs for display + const html = chapterData.text + ? '

    ' + chapterData.text.replace(/\n{2,}/g, '

    ').replace(/\n/g, '
    ') + '

    ' + : ''; + + // Fetch voices (non-critical for preview) + let voices: string[] = []; + try { + const vRes = await fetch(`${SCRAPER_URL}/api/voices`); + if (vRes.ok) { + const d = (await vRes.json()) as { voices: string[] }; + voices = d.voices ?? []; + } + } catch { + // Non-critical + } + + // Try to get book title/cover from PocketBase for breadcrumbs; fall back to slug + const pb = await getBook(slug).catch(() => null); + + return { + book: { + slug, + title: pb?.title ?? slug, + cover: pb?.cover ?? '' + }, + chapter: { + id: '', + slug, + number: n, + title: chapterData.title || `Chapter ${n}`, + date_label: '' + }, + html, + voices, + prev: null as number | null, + next: null as number | null, + chapters: [] as { number: number; title: string }[], + sessionId: locals.sessionId, + isPreview: true + }; + } + + // ── Normal path: fetch from PocketBase + MinIO ───────────────────────── + // Fetch book metadata, chapter index, and voice list in parallel + const [book, chapters, voicesRes] = await Promise.all([ + getBook(slug), + listChapterIdx(slug), + fetch(`${SCRAPER_URL}/api/voices`).catch(() => null) + ]); + + if (!book) error(404, `Book "${slug}" not found`); + + const chapterIdx = chapters.find((c) => c.number === n); + if (!chapterIdx) error(404, `Chapter ${n} not found`); + + // Parse voices — fall back to a minimal default list on error + let voices: string[] = []; + try { + if (voicesRes?.ok) { + const data = (await voicesRes.json()) as { voices: string[] }; + voices = data.voices ?? []; + } + } catch { + // Non-critical — UI will use store default + } + + // Fetch chapter markdown directly from the scraper (server-side MinIO read) + let html = ''; + try { + const res = await fetch(`${SCRAPER_URL}/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`); + if (!res.ok) { + log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status }); + error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content'); + } + const markdown = await res.text(); + html = marked(markdown) as string; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + // Don't hard-fail — show empty content with error message + log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) }); + error(502, 'Could not fetch chapter content'); + } + + const prevChapter = chapters.find((c) => c.number === n - 1) ?? null; + const nextChapter = chapters.find((c) => c.number === n + 1) ?? null; + + return { + book: { slug: book.slug, title: book.title, cover: book.cover ?? '' }, + chapter: chapterIdx, + html, + voices, + prev: prevChapter ? prevChapter.number : null, + next: nextChapter ? nextChapter.number : null, + chapters: chapters.map((c) => ({ number: c.number, title: c.title })), + sessionId: locals.sessionId, + isPreview: false + }; +}; diff --git a/ui-v2/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui-v2/src/routes/books/[slug]/chapters/[n]/+page.svelte new file mode 100644 index 0000000..de7212c --- /dev/null +++ b/ui-v2/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -0,0 +1,161 @@ + + + + {data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel + + + +
    + + + + + Chapters + + +
    + {#if data.prev} + + ← Ch.{data.prev} + + {/if} + {#if data.next} + + Ch.{data.next} → + + {/if} +
    +
    + + +
    +

    + {data.chapter.title || `Chapter ${data.chapter.number}`} +

    + {#if wordCount > 0} +

    {wordCount.toLocaleString()} words

    + {/if} +
    + + +{#if !data.isPreview} + +{:else} +
    + Preview chapter — audio not available for books outside the library. +
    +{/if} + + +{#if fetchingContent} +
    + + + + + Fetching chapter… +
    +{:else if !html} +
    +

    {fetchError || 'Chapter content not available.'}

    +
    +{:else} +
    + {@html html} +
    +{/if} + + +
    + {#if data.prev} + + ← Previous chapter + + {:else} +
    + {/if} + {#if data.next} + + Next chapter → + + {/if} +
    diff --git a/ui-v2/src/routes/browse/+page.server.ts b/ui-v2/src/routes/browse/+page.server.ts new file mode 100644 index 0000000..85e4e19 --- /dev/null +++ b/ui-v2/src/routes/browse/+page.server.ts @@ -0,0 +1,170 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +export interface NovelListing { + slug: string; + title: string; + cover: string; + rank: string; + rating: string; + chapters: string; + url: string; + // enriched fields (only set when sort=rank) + author?: string; + status?: string; + genres?: string[]; + source_url?: string; +} + +export const load: PageServerLoad = async ({ url, locals }) => { + const page = url.searchParams.get('page') ?? '1'; + const genre = url.searchParams.get('genre') ?? 'all'; + const sort = url.searchParams.get('sort') ?? 'popular'; + const status = url.searchParams.get('status') ?? 'all'; + const q = url.searchParams.get('q') ?? ''; + + let novels: NovelListing[] = []; + let pageNum = parseInt(page, 10) || 1; + let hasNext = false; + let searchQuery = ''; + let searchLocalCount = 0; + let searchRemoteCount = 0; + + // ── Search mode: ?q= overrides browse/ranking ───────────────────────── + if (q.trim().length >= 2) { + searchQuery = q.trim(); + const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(searchQuery)}`; + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('browse', 'search returned error', { status: res.status }); + throw error(502, `Search failed: ${res.status}`); + } + const data: { + results: NovelListing[]; + local_count: number; + remote_count: number; + } = await res.json(); + novels = data.results ?? []; + searchLocalCount = data.local_count ?? 0; + searchRemoteCount = data.remote_count ?? 0; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('browse', 'search network error', { q: searchQuery, err: String(e) }); + throw error(502, 'Could not reach search service'); + } + + return { + novels, + page: 1, + hasNext: false, + genre, + sort, + status, + isAdmin: locals.user?.role === 'admin', + searchQuery, + searchLocalCount, + searchRemoteCount + }; + } + + if (sort === 'rank') { + // Ranking view: fetch from /api/ranking which returns richer metadata. + // Pagination and filters (genre/status) don't apply here — the ranking + // is a single pre-computed list from the last catalogue scrape. + const apiURL = `${SCRAPER_URL}/api/ranking`; + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('browse', 'scraper ranking returned error', { status: res.status }); + throw error(502, `Ranking fetch failed: ${res.status}`); + } + const items: Array<{ + rank: number; + slug: string; + title: string; + author: string; + cover: string; + status: string; + genres: string[]; + source_url: string; + }> = await res.json(); + novels = (items ?? []).map((item) => ({ + slug: item.slug, + title: item.title, + cover: item.cover, + rank: item.rank != null ? `#${item.rank}` : '', + rating: '', + chapters: '', + url: item.source_url ?? '', + author: item.author, + status: item.status, + genres: item.genres ?? [], + source_url: item.source_url + })); + pageNum = 1; + hasNext = false; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('browse', 'scraper ranking network error', { err: String(e) }); + throw error(502, 'Could not load ranking'); + } + } else { + // Browse view: paginated catalogue from /api/browse. + const params = new URLSearchParams({ page, genre, sort, status }); + const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`; + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL }); + throw error(502, `Browse fetch failed: ${res.status}`); + } + const data: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json(); + novels = data.novels ?? []; + pageNum = data.page ?? 1; + hasNext = data.hasNext ?? false; + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) }); + throw error(502, 'Could not load browse page'); + } + } + + return { + novels, + page: pageNum, + hasNext, + genre, + sort, + status, + isAdmin: locals.user?.role === 'admin', + searchQuery: '', + searchLocalCount: 0, + searchRemoteCount: 0 + }; +}; + +// Admin action: trigger a full catalogue scrape (refreshes ranking + library). +export const actions: Actions = { + refresh: async ({ locals, fetch }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + try { + const res = await fetch('/api/scrape', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + if (res.status === 409) return { status: 'busy' }; + if (!res.ok) return { status: 'error' }; + return { status: 'queued' }; + } catch { + return { status: 'error' }; + } + } +}; diff --git a/ui-v2/src/routes/browse/+page.svelte b/ui-v2/src/routes/browse/+page.svelte new file mode 100644 index 0000000..a87c67d --- /dev/null +++ b/ui-v2/src/routes/browse/+page.svelte @@ -0,0 +1,700 @@ + + + + Discover — libnovel + + + +
    +

    Discover

    +

    + {#if isSearchView} + {novels.length} result{novels.length !== 1 ? 's' : ''} for "{data.searchQuery}" + {#if data.searchLocalCount > 0 || data.searchRemoteCount > 0} + ({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire) + {/if} + {:else if isRankView} + {#if novels.length > 0} + {novels.length} novels ranked from last catalogue scrape + {:else} + No ranking data — run a full catalogue scrape to populate + {/if} + {:else} + Browse novels from novelfire.net + {/if} +

    +
    + + +{#if form} + {#if form.status === 'queued'} +
    + Full catalogue scrape queued. Library and ranking will update as books are processed. +
    + {:else if form.status === 'busy'} +
    + A scrape job is already running. Check back once it finishes. +
    + {:else if form.status === 'error'} +
    + Failed to queue scrape. Check that the scraper service is reachable. +
    + {/if} +{/if} + + +
    + +
    + + + {#if data.searchQuery} + + Clear + + {/if} +
    + + + + + +
    + + +
    + + + {#if data.isAdmin} +
    { + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + > + +
    + {/if} +
    + + +{#if !filtersOpen && hasActiveFilters} +

    + {filterSummary()} + clear +

    +{/if} + + +{#if filtersOpen} + + {#if data.isAdmin} +
    { + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + class="sm:hidden mb-2" + > + +
    + {/if} + +
    + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + + {#if isRankView} +

    Genre & status filters apply to Browse only

    + {/if} + +
    + + Reset + + +
    +
    +{/if} + + +{#if novels.length === 0} +
    +

    {isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}

    +

    + {#if isSearchView} + Try a different search term. + {:else if isRankView} + {#if data.isAdmin} + Click Refresh catalogue above to trigger a full catalogue scrape. + {:else} + Ask an admin to run a catalogue scrape. + {/if} + {:else} + Try different filters or check back later. + {/if} +

    +
    + +{:else if view === 'grid'} + + + +{:else} + +
    + {#each novels as novel} + {@const isLoading = loadingSlug === novel.slug} +
    + + {#if novel.rank} + {novel.rank} + {/if} + + +
    + {#if novel.cover} + {novel.title} + {:else} +
    + + + +
    + {/if} + {#if isLoading} +
    + + + + +
    + {/if} +
    + + +
    + {#if novel.slug} + handleNovelClick(novel.slug)} + class="text-sm font-semibold transition-colors line-clamp-1 + {isLoading ? 'text-amber-400' : 'text-zinc-100 hover:text-amber-400'}" + > + {novel.title} + + {:else} + {novel.title} + {/if} +
    + {#if novel.author} + {novel.author} + {/if} + {#if novel.status} + {novel.status} + {:else if novel.chapters} + {novel.chapters} + {/if} + {#if novel.rating} + ★ {novel.rating} + {/if} + {#if novel.genres?.length} + {#each novel.genres.slice(0, 3) as genre} + {genre} + {/each} + {/if} +
    +
    + + + {#if data.isAdmin && novel.url} +
    + {#if scrapeResult[novel.slug] === 'queued'} + Queued + {:else if scrapeResult[novel.slug] === 'busy'} + Busy + {:else if scrapeResult[novel.slug] === 'error'} + Error + {:else} + + {/if} +
    + {/if} + + + {#if novel.source_url || novel.url} + + + + + + {/if} +
    + {/each} +
    +{/if} + + +{#if !isRankView && !isSearchView} + {#if hasNext} + +
    + {/if} + + + {#if loadingMore} +
    + + + + +
    + {:else if !hasNext && novels.length > 0} +

    All novels loaded

    + {/if} +{/if} + + +{#if showScrollTop} + +{/if} diff --git a/ui-v2/src/routes/disclaimer/+page.svelte b/ui-v2/src/routes/disclaimer/+page.svelte new file mode 100644 index 0000000..db34bbe --- /dev/null +++ b/ui-v2/src/routes/disclaimer/+page.svelte @@ -0,0 +1,34 @@ + + Disclaimer — libnovel + + +
    +

    Disclaimer

    + +
    +

    + libnovel is a personal reading tool that indexes and caches publicly accessible novel content + from third-party sources, primarily novelfire.net. + It is not affiliated with, endorsed by, or in any way officially connected to those sources. +

    + +

    + All novel titles, cover images, chapter text, and related materials are the property of their + respective authors and publishers. libnovel does not claim ownership of any of this content. + The content is reproduced solely for personal, non-commercial reading convenience. +

    + +

    + If you are a rights holder and believe your work is being used without authorisation, please + refer to our DMCA policy + for instructions on how to request removal. +

    + +

    + libnovel makes no warranties regarding the accuracy, completeness, or timeliness of any + content displayed. Use of this site is at your own risk. +

    + +

    Last updated: {new Date().getFullYear()}

    +
    +
    diff --git a/ui-v2/src/routes/dmca/+page.svelte b/ui-v2/src/routes/dmca/+page.svelte new file mode 100644 index 0000000..8a73ae6 --- /dev/null +++ b/ui-v2/src/routes/dmca/+page.svelte @@ -0,0 +1,45 @@ + + DMCA — libnovel + + +
    +

    DMCA Takedown Policy

    + +
    +

    + libnovel respects the intellectual property rights of authors, publishers, and other content + creators. If you believe that content available through this site infringes your copyright, + please send a written takedown notice to the contact address below. +

    + +

    Your notice must include

    +
      +
    1. Your full legal name and contact information (email address).
    2. +
    3. A description of the copyrighted work you claim has been infringed.
    4. +
    5. The specific URL(s) on this site where the allegedly infringing content appears.
    6. +
    7. + A statement that you have a good-faith belief that the use is not authorised by the copyright + owner, its agent, or the law. +
    8. +
    9. + A statement, made under penalty of perjury, that the information in your notice is accurate + and that you are the copyright owner or authorised to act on their behalf. +
    10. +
    11. Your electronic or physical signature.
    12. +
    + +

    How to submit

    +

    + Send your notice by email to dmca@libnovel.local. + We will review valid notices and remove or disable access to the identified content promptly. +

    + +

    Counter-notices

    +

    + If you believe content was removed in error, you may submit a counter-notice to the same + address with the information required under 17 U.S.C. § 512(g)(3). +

    + +

    Last updated: {new Date().getFullYear()}

    +
    +
    diff --git a/ui-v2/src/routes/health/+server.ts b/ui-v2/src/routes/health/+server.ts new file mode 100644 index 0000000..a7b2832 --- /dev/null +++ b/ui-v2/src/routes/health/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = () => { + return json({ status: 'ok' }); +}; diff --git a/ui-v2/src/routes/login/+page.server.ts b/ui-v2/src/routes/login/+page.server.ts new file mode 100644 index 0000000..69e6211 --- /dev/null +++ b/ui-v2/src/routes/login/+page.server.ts @@ -0,0 +1,142 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase'; +import { createAuthToken } from '../../hooks.server'; +import { log } from '$lib/server/logger'; +import { randomBytes } from 'node:crypto'; + +const AUTH_COOKIE = 'libnovel_auth'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +export const load: PageServerLoad = async ({ locals }) => { + // Already logged in — send to home + if (locals.user) { + redirect(302, '/'); + } + return {}; +}; + +export const actions: Actions = { + login: async ({ request, cookies, locals }) => { + const data = await request.formData(); + const username = (data.get('username') as string | null)?.trim() ?? ''; + const password = (data.get('password') as string | null) ?? ''; + + if (!username || !password) { + return fail(400, { action: 'login', error: 'Username and password are required.' }); + } + + let user; + try { + user = await loginUser(username, password); + } catch (err) { + log.error('auth', 'login unexpected error', { username, err: String(err) }); + return fail(500, { action: 'login', error: 'An error occurred. Please try again.' }); + } + + if (!user) { + return fail(401, { action: 'login', error: 'Invalid username or password.' }); + } + + // Merge any anonymous session progress into the user's account so that + // chapters read before logging in are preserved and portable across devices. + mergeSessionProgress(locals.sessionId, user.id).catch((err) => + log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) }) + ); + + // Create a unique auth session ID for this login + const authSessionId = randomBytes(16).toString('hex'); + + // Record the session in PocketBase (best-effort, non-fatal) + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => + log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + redirect(302, '/'); + }, + + register: async ({ request, cookies, locals }) => { + const data = await request.formData(); + const username = (data.get('username') as string | null)?.trim() ?? ''; + const password = (data.get('password') as string | null) ?? ''; + const confirm = (data.get('confirm') as string | null) ?? ''; + + if (!username || !password) { + return fail(400, { action: 'register', error: 'Username and password are required.' }); + } + if (username.length < 3 || username.length > 32) { + return fail(400, { + action: 'register', + error: 'Username must be between 3 and 32 characters.' + }); + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return fail(400, { + action: 'register', + error: 'Username may only contain letters, numbers, underscores and hyphens.' + }); + } + if (password.length < 8) { + return fail(400, { + action: 'register', + error: 'Password must be at least 8 characters.' + }); + } + if (password !== confirm) { + return fail(400, { action: 'register', error: 'Passwords do not match.' }); + } + + let user; + try { + user = await createUser(username, password); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Registration failed.'; + if (msg.includes('Username already taken')) { + return fail(409, { action: 'register', error: 'That username is already taken.' }); + } + log.error('auth', 'register unexpected error', { username, err: String(err) }); + return fail(500, { action: 'register', error: 'An error occurred. Please try again.' }); + } + + // Merge any anonymous session progress into the newly created account. + mergeSessionProgress(locals.sessionId, user.id).catch((err) => + log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) }) + ); + + // Create a unique auth session ID for this registration + const authSessionId = randomBytes(16).toString('hex'); + + // Record the session in PocketBase (best-effort, non-fatal) + const userAgent = request.headers.get('user-agent') ?? ''; + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + ''; + createUserSession(user.id, authSessionId, userAgent, ip).catch((err) => + log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) }) + ); + + const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId); + cookies.set(AUTH_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + + redirect(302, '/'); + } +}; diff --git a/ui-v2/src/routes/login/+page.svelte b/ui-v2/src/routes/login/+page.svelte new file mode 100644 index 0000000..77a5a13 --- /dev/null +++ b/ui-v2/src/routes/login/+page.svelte @@ -0,0 +1,136 @@ + + + + Sign in — libnovel + + +
    +
    + +
    + + +
    + + {#if form?.error && (form?.action === mode || !form?.action)} +
    + {form.error} +
    + {/if} + + {#if mode === 'login'} +
    +
    + + +
    +
    + + +
    + +
    + {:else} +
    +
    + + +

    3–32 characters: letters, numbers, _ or -

    +
    +
    + + +

    At least 8 characters

    +
    +
    + + +
    + +
    + {/if} +
    +
    diff --git a/ui-v2/src/routes/logout/+page.server.ts b/ui-v2/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..9af7c4b --- /dev/null +++ b/ui-v2/src/routes/logout/+page.server.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +const AUTH_COOKIE = 'libnovel_auth'; + +export const actions: Actions = { + default: async ({ cookies }) => { + cookies.delete(AUTH_COOKIE, { path: '/' }); + redirect(302, '/login'); + } +}; diff --git a/ui-v2/src/routes/privacy/+page.svelte b/ui-v2/src/routes/privacy/+page.svelte new file mode 100644 index 0000000..e90e99b --- /dev/null +++ b/ui-v2/src/routes/privacy/+page.svelte @@ -0,0 +1,55 @@ + + Privacy Policy — libnovel + + +
    +

    Privacy Policy

    + +
    +

    + This policy describes what limited data libnovel collects and how it is used. +

    + +

    Data we collect

    +
      +
    • + Session cookies — a short-lived cookie is set when you + visit the site to track reading progress across pages. No account is required. +
    • +
    • + Account data (optional) — if you create an account, + we store your username and a hashed password. No email address is required. +
    • +
    • + Reading progress — the last chapter you read for each + book is stored server-side, tied to your session or account, so you can resume reading. +
    • +
    • + Saved books — books you explicitly bookmark are stored + server-side tied to your session or account. +
    • +
    + +

    What we do not collect

    +
      +
    • No email addresses (unless you choose to provide one).
    • +
    • No tracking pixels, analytics scripts, or third-party ad networks.
    • +
    • No selling or sharing of data with third parties.
    • +
    + +

    Third-party content

    +

    + Cover images and chapter content are fetched from third-party sources (e.g. + novelfire.net). + Your browser may make requests directly to those domains when loading images. +

    + +

    Data deletion

    +

    + You can delete your reading progress and saved books from your profile page at any time. + To request full account deletion, contact us via the contact address listed in our DMCA policy. +

    + +

    Last updated: {new Date().getFullYear()}

    +
    +
    diff --git a/ui-v2/src/routes/profile/+page.server.ts b/ui-v2/src/routes/profile/+page.server.ts new file mode 100644 index 0000000..587337b --- /dev/null +++ b/ui-v2/src/routes/profile/+page.server.ts @@ -0,0 +1,79 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) { + redirect(302, '/login'); + } + + let sessions: Awaited> = []; + try { + sessions = await listUserSessions(locals.user.id); + } catch (e) { + log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) }); + } + + // Fetch avatar presigned URL if user has one + let avatarUrl: string | null = null; + try { + const record = await getUserByUsername(locals.user.username); + if (record?.avatar_url) { + avatarUrl = await presignAvatarUrl(locals.user.id); + } + } catch (e) { + log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) }); + } + + return { + user: locals.user, + avatarUrl, + sessions: sessions.map((s) => ({ + id: s.id, + user_agent: s.user_agent, + ip: s.ip, + created_at: s.created_at, + last_seen: s.last_seen, + is_current: s.session_id === locals.user!.authSessionId + })) + }; +}; + +export const actions: Actions = { + changePassword: async ({ request, locals }) => { + if (!locals.user) { + return fail(401, { error: 'Not logged in.' }); + } + + const data = await request.formData(); + const current = (data.get('current') as string | null) ?? ''; + const next = (data.get('next') as string | null) ?? ''; + const confirm = (data.get('confirm') as string | null) ?? ''; + + if (!current || !next || !confirm) { + return fail(400, { error: 'All fields are required.' }); + } + if (next.length < 8) { + return fail(400, { error: 'New password must be at least 8 characters.' }); + } + if (next !== confirm) { + return fail(400, { error: 'New passwords do not match.' }); + } + + let ok: boolean; + try { + ok = await changePassword(locals.user.id, current, next); + } catch (e) { + log.error('profile', 'changePassword failed', { err: String(e) }); + return fail(500, { error: 'An error occurred. Please try again.' }); + } + + if (!ok) { + return fail(401, { error: 'Current password is incorrect.' }); + } + + return { success: true }; + } +}; diff --git a/ui-v2/src/routes/profile/+page.svelte b/ui-v2/src/routes/profile/+page.svelte new file mode 100644 index 0000000..883d91f --- /dev/null +++ b/ui-v2/src/routes/profile/+page.svelte @@ -0,0 +1,474 @@ + + + + Profile — libnovel + + +{#if cropFile && browser} + {#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }} + + {/await} +{/if} + + + + +
    +
    + +
    + + +
    + +
    +

    {data.user.username}

    +

    {data.user.role}

    + {#if avatarError} +

    {avatarError}

    + {:else} +

    Click avatar to change photo

    + {/if} +
    +
    + + +
    +

    Reading settings

    + + +
    + + {#if !voicesLoaded} +
    + {:else if voices.length === 0} + + {:else} + + {/if} +
    + + +
    + + +
    + 0.5x + 3.0x +
    +
    + + + + +
    + + {#if settingsSaved} + Saved! + {/if} +
    +
    + + +
    +

    Active sessions

    +

    These are all devices currently signed into your account. End any session you don't recognise.

    + + {#if revokeError} +
    + {revokeError} +
    + {/if} + + {#if sessions.length === 0} +

    No session records found. Sessions are tracked from the next login.

    + {:else} +
      + {#each sessions as session (session.id)} +
    • +
      +
      + {parseUA(session.user_agent)} + {#if session.is_current} + This session + {/if} +
      + {#if session.ip} +

      {session.ip}

      + {/if} +

      + Signed in {formatDate(session.created_at)} + {#if session.last_seen && session.last_seen !== session.created_at} + · Last seen {formatDate(session.last_seen)} + {/if} +

      +
      + +
    • + {/each} +
    + {/if} +
    + + +
    +

    Change password

    + + {#if form?.error} +
    + {form.error} +
    + {/if} + + {#if pwSuccess} +
    + Password changed successfully. +
    + {/if} + +
    { + pwSubmitting = true; + return async ({ update }) => { + pwSubmitting = false; + await update(); + }; + }} + class="space-y-4" + > +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    diff --git a/ui-v2/src/routes/users/[username]/+page.server.ts b/ui-v2/src/routes/users/[username]/+page.server.ts new file mode 100644 index 0000000..fa9cfe6 --- /dev/null +++ b/ui-v2/src/routes/users/[username]/+page.server.ts @@ -0,0 +1,59 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { + getPublicProfile, + getSubscription, + getUserPublicLibrary, + getUserCurrentlyReading +} from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const { username } = params; + + const profile = await getPublicProfile(username).catch(() => null); + if (!profile) error(404, `User "${username}" not found`); + + // Resolve avatar + let avatarUrl: string | null = null; + if (profile.avatar_url) { + avatarUrl = await presignAvatarUrl(profile.id).catch(() => null); + } + + // Subscription state for the logged-in visitor + let isSubscribed = false; + const isSelf = locals.user?.id === profile.id; + if (locals.user && !isSelf) { + const sub = await getSubscription(locals.user.id, profile.id).catch(() => null); + isSubscribed = !!sub; + } + + // Load public library + currently reading in parallel + const [library, currentlyReading] = await Promise.all([ + getUserPublicLibrary(profile.id).catch((e) => { + log.error('users/profile', 'getUserPublicLibrary failed', { username, err: String(e) }); + return [] as Awaited>; + }), + getUserCurrentlyReading(profile.id).catch((e) => { + log.error('users/profile', 'getUserCurrentlyReading failed', { username, err: String(e) }); + return [] as Awaited>; + }) + ]); + + return { + profile: { + id: profile.id, + username: profile.username, + created: profile.created, + followerCount: profile.followerCount, + followingCount: profile.followingCount + }, + avatarUrl, + isSubscribed, + isSelf, + isLoggedIn: !!locals.user, + library, + currentlyReading + }; +}; diff --git a/ui-v2/src/routes/users/[username]/+page.svelte b/ui-v2/src/routes/users/[username]/+page.svelte new file mode 100644 index 0000000..cde8afc --- /dev/null +++ b/ui-v2/src/routes/users/[username]/+page.svelte @@ -0,0 +1,225 @@ + + + + {data.profile.username} — libnovel + + + +
    + +
    + {#if data.avatarUrl} + {data.profile.username} + {:else} +
    + {initials(data.profile.username)} +
    + {/if} +
    + + +
    +

    {data.profile.username}

    +

    Joined {joinDate(data.profile.created)}

    + + +
    + + {followerCount} + followers + + + {data.profile.followingCount} + following + +
    + + + {#if data.isLoggedIn && !data.isSelf} + + {:else if !data.isLoggedIn} + + Follow + + {/if} +
    +
    + + +{#if data.currentlyReading.length > 0} +
    +

    Currently Reading

    + +
    +{/if} + + +{#if data.library.length > 0} +
    +

    + Library + ({data.library.length}) +

    + +
    +{/if} + + +{#if data.library.length === 0 && data.currentlyReading.length === 0} +
    + + + +

    No books in library yet.

    +
    +{/if} diff --git a/ui-v2/static/apple-touch-icon.png b/ui-v2/static/apple-touch-icon.png new file mode 100644 index 0000000..07ba559 Binary files /dev/null and b/ui-v2/static/apple-touch-icon.png differ diff --git a/ui-v2/static/favicon-16.png b/ui-v2/static/favicon-16.png new file mode 100644 index 0000000..90771d0 Binary files /dev/null and b/ui-v2/static/favicon-16.png differ diff --git a/ui-v2/static/favicon-32.png b/ui-v2/static/favicon-32.png new file mode 100644 index 0000000..c9cca0b Binary files /dev/null and b/ui-v2/static/favicon-32.png differ diff --git a/ui-v2/static/favicon.ico b/ui-v2/static/favicon.ico new file mode 100644 index 0000000..9aea163 Binary files /dev/null and b/ui-v2/static/favicon.ico differ diff --git a/ui-v2/static/icon-192.png b/ui-v2/static/icon-192.png new file mode 100644 index 0000000..17b9214 Binary files /dev/null and b/ui-v2/static/icon-192.png differ diff --git a/ui-v2/static/icon-512.png b/ui-v2/static/icon-512.png new file mode 100644 index 0000000..3135efa Binary files /dev/null and b/ui-v2/static/icon-512.png differ diff --git a/ui-v2/static/robots.txt b/ui-v2/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/ui-v2/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/ui-v2/svelte.config.js b/ui-v2/svelte.config.js new file mode 100644 index 0000000..6bfb3c4 --- /dev/null +++ b/ui-v2/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/ui-v2/tsconfig.json b/ui-v2/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/ui-v2/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/ui-v2/vite.config.ts b/ui-v2/vite.config.ts new file mode 100644 index 0000000..bb50a3d --- /dev/null +++ b/ui-v2/vite.config.ts @@ -0,0 +1,18 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + ssr: { + // Force these packages to be bundled into the server output rather than + // treated as external requires. The production Docker image has no + // node_modules, so anything used in server-side code must be inlined. + noExternal: ['marked'], + // cropperjs is DOM-only (used inside $effect); exclude from SSR bundle. + external: ['cropperjs'] + }, + optimizeDeps: { + include: ['cropperjs'] + } +});