feat(v3): add v3 stack — backend rewrite, renamed env vars, docs

- New Go backend binary (backend + runner) replacing old scraper/
- Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose
- Rename scraperFetch → backendFetch across all 19 UI server files
- Remove SCRAPER_PROXY env var and proxy transport from browser.Config
- Add Meilisearch, Valkey, Caddy to docker-compose
- Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
This commit is contained in:
Admin
2026-03-22 17:27:32 +05:00
parent 29d0eeb7e8
commit a85636d5db
178 changed files with 25951 additions and 0 deletions

83
v3/Caddyfile Normal file
View File

@@ -0,0 +1,83 @@
# v3/Caddyfile
#
# Caddy reverse proxy for LibNovel v3.
#
# Environment variables consumed (set in docker-compose.yml):
# DOMAIN — public hostname, e.g. libnovel.example.com
# Use "localhost" for local dev (no TLS cert attempted).
# CADDY_ACME_EMAIL — Let's Encrypt notification email (empty = no email)
#
# Routing rules:
# /health → backend:8080 (liveness probe)
# /scrape* → backend:8080 (scrape task creation)
# /api/browse → backend:8080 (MinIO-cached browse pages)
# /api/book-preview/* → backend:8080 (live scrape, no store write)
# /api/chapter-text-preview/*/* → backend:8080 (live chapter, no store write)
# /api/chapter-text/*/* → backend:8080 (chapter markdown from MinIO)
# /api/reindex/* → backend:8080 (rebuild chapter index)
# /api/cover/* → backend:8080 (proxy cover image)
# /api/audio-proxy/*/* → backend:8080 (proxy generated audio)
# /api/scrape/* → backend:8080 (scrape job status/tasks)
# /* (everything else) → ui:3000 (SvelteKit — handles all
# remaining /api/* routes too)
#
# The SvelteKit UI itself proxies to the backend for: ranking, voices, search,
# browse-page, presign, audio, progress, and the Go /api/progress endpoint.
# MinIO and PocketBase are NOT exposed publicly.
{
# Email for Let's Encrypt ACME account registration.
# Optional — omit to use an anonymous ACME account.
{$CADDY_ACME_EMAIL:}
}
{$DOMAIN:localhost} {
# ── Liveness probe ───────────────────────────────────────────────────────
handle /health {
reverse_proxy backend:8080
}
# ── Scrape task creation (Go backend only) ───────────────────────────────
handle /scrape* {
reverse_proxy backend:8080
}
# ── Backend-only API paths ────────────────────────────────────────────────
# These paths are served exclusively by the Go scraper and are not
# implemented in the SvelteKit UI.
handle /api/browse {
reverse_proxy backend:8080
}
handle /api/book-preview/* {
reverse_proxy backend:8080
}
handle /api/chapter-text-preview/* {
reverse_proxy backend:8080
}
handle /api/chapter-text/* {
reverse_proxy backend:8080
}
handle /api/reindex/* {
reverse_proxy backend:8080
}
handle /api/cover/* {
reverse_proxy backend:8080
}
handle /api/audio-proxy/* {
reverse_proxy backend:8080
}
handle /api/scrape/* {
reverse_proxy backend:8080
}
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
handle {
reverse_proxy ui:3000
}
# ── Logging ──────────────────────────────────────────────────────────────
log {
output stdout
format json
}
}
}

215
v3/TODO.md Normal file
View File

@@ -0,0 +1,215 @@
# v3 Implementation Plan
v3 is a self-contained directory. All services live under `v3/`. Nothing shares
code with the root `backend/` or `ui-v2/` directories.
## New in v3 vs v2
| Addition | Why |
|----------|-----|
| **Caddy** reverse proxy | Single public entry point, automatic HTTPS via Let's Encrypt, no manual cert management |
| **Meilisearch** | Full-text search over locally scraped books; replaces live novelfire.net scrape on every search |
| **Valkey** (Redis-compatible) | Shared presign URL cache; replaces the per-process in-memory Map in the UI Node process |
| **Runner `/metrics` HTTP endpoint** | Exposes task counters (pending, running, failed, completed) for healthcheck / Caddy visibility |
## Directory layout (target)
```
v3/
├── backend/ # copied from ../backend/, then modified
│ ├── cmd/
│ │ ├── backend/ # unchanged
│ │ └── runner/ # add metrics HTTP server
│ ├── internal/
│ │ ├── backend/
│ │ │ ├── server.go # add /api/search endpoint (Meilisearch)
│ │ │ └── handlers.go # add searchHandler, presign now reads Valkey
│ │ ├── runner/
│ │ │ ├── runner.go # add atomic counters + metrics HTTP server
│ │ │ └── metrics.go # NEW: MetricsServer (net/http, /metrics JSON)
│ │ ├── meili/ # NEW: Meilisearch client package
│ │ │ └── client.go
│ │ ├── presigncache/ # NEW: Valkey-backed presign cache
│ │ │ └── cache.go
│ │ └── config/
│ │ └── config.go # add Meilisearch + Valkey + metrics addr vars
│ └── go.mod # add meilisearch-go, go-redis/v9
├── ui/ # copied from ../ui-v2/, then modified
│ └── src/lib/server/
│ └── presignCache.ts # replace in-process Map with Valkey (ioredis)
├── docs/
│ ├── architecture.d2 # DONE
│ └── architecture.mermaid.md # DONE
├── scripts/
│ └── pb-init-v3.sh # same as v2 unless schema changes
├── Caddyfile # NEW
├── docker-compose.yml # NEW
└── TODO.md # this file
```
---
## Tasks
### 1. Copy source trees
- [ ] `cp -r ../backend v3/backend`
- [ ] `cp -r ../ui-v2 v3/ui`
- [ ] `cp ../scripts/pb-init-v2.sh v3/scripts/pb-init-v3.sh`
- [ ] Verify `v3/backend/go.mod` module path is still `github.com/libnovel/backend`
(no rename needed — it's a copy, not a Go workspace dep)
### 2. Add Valkey presign cache (`v3/backend`)
**Goal**: Backend generates presigned URLs → stores in Valkey with TTL.
UI reads from Valkey before calling backend.
Files to create/modify:
- [ ] `internal/presigncache/cache.go``Cache` interface + `ValkeyCache` implementation
- `Get(ctx, key) (string, bool, error)`
- `Set(ctx, key, url string, ttl time.Duration) error`
- Uses `github.com/redis/go-redis/v9`
- TTL: match presigned URL lifetime (e.g. 45 min; MinIO default is 1h → use 55 min)
- [ ] `internal/config/config.go` — add `ValkeyAddr string` (default `"valkey:6379"`)
- [ ] `internal/backend/server.go` — add `PresignCache presigncache.Cache` to `Dependencies`
- [ ] `internal/backend/handlers.go` — in presign handlers, do `cache.Get` before generating,
`cache.Set` after generating
- [ ] `go.mod` — add `github.com/redis/go-redis/v9`
- [ ] `cmd/backend/main.go` — wire `ValkeyCache` from config
### 3. Add Meilisearch integration (`v3/backend`)
**Goal**: On each book scrape completion, upsert the book document into Meilisearch.
Backend `/api/search` returns Meilisearch results for local books, falling back to
novelfire.net live search for books not in the index.
Files to create/modify:
- [ ] `internal/meili/client.go``Client` interface + `MeiliClient` implementation
- `UpsertBook(ctx, book domain.Book) error`
- `Search(ctx, query string, limit int) ([]domain.Book, error)`
- Uses `github.com/meilisearch/meilisearch-go`
- Index name: `books`, primary key: `slug`
- Searchable attributes: `title`, `author`, `tags`, `description`
- Filterable attributes: `status`, `tags`
- [ ] `internal/config/config.go` — add `MeiliURL string`, `MeiliAPIKey string`
- [ ] `internal/bookstore/bookstore.go` — add `SearchIndex` interface with `UpsertBook`
- [ ] `internal/runner/runner.go` — inject `SearchIndex`; after `FinishScrapeTask` success,
call `SearchIndex.UpsertBook` for each book scraped
- [ ] `internal/backend/server.go` — add `SearchIndex` to `Dependencies`; register `GET /api/search`
- [ ] `internal/backend/handlers.go``searchHandler`: query Meilisearch first; if 0 results,
fall back to novelfire.net live search via `NovelScraper.SearchBooks`
- [ ] `go.mod` — add `github.com/meilisearch/meilisearch-go`
- [ ] `cmd/backend/main.go` + `cmd/runner/main.go` — wire `MeiliClient`
### 4. Add runner `/metrics` HTTP endpoint (`v3/backend`)
**Goal**: Runner exposes `GET /metrics` returning JSON with task counters. Caddy can
healthcheck this; operators can scrape it from a monitoring tool.
Payload example:
```json
{
"tasks_pending": 0,
"tasks_running": 1,
"tasks_completed": 42,
"tasks_failed": 2,
"uptime_seconds": 3600
}
```
Files to create/modify:
- [ ] `internal/runner/metrics.go``Metrics` struct with `atomic.Int64` counters;
`ServeHTTP` handler returns JSON; `MetricsServer` starts `net/http` on configurable addr
- [ ] `internal/runner/runner.go` — embed `*Metrics`; increment counters at task lifecycle
points (claimed → running → completed/failed)
- [ ] `internal/config/config.go` — add `RunnerMetricsAddr string` (default `":9091"`)
- [ ] `cmd/runner/main.go` — start `MetricsServer` in background goroutine before `runner.Run`
- [ ] `docker-compose.yml` (runner service) — expose port 9091 internally; update healthcheck
to `GET http://localhost:9091/metrics` (replaces file-based liveness)
### 5. Update UI presign cache to use Valkey (`v3/ui`)
**Goal**: Replace `src/lib/server/presignCache.ts` (module-scope `Map`) with Valkey via
`ioredis`. Cache survives UI restarts and is shared if multiple UI replicas run.
Files to modify:
- [ ] `src/lib/server/presignCache.ts` — rewrite to use `ioredis` client
- `getPresignedUrl(key: string): Promise<string | null>`
- `setPresignedUrl(key: string, url: string, ttlSeconds: number): Promise<void>`
- Connection string from `VALKEY_URL` env var (default `redis://valkey:6379`)
- Keep the same TTL logic (50 min) and sweep logic (not needed — Valkey TTL is native)
- [ ] `package.json` — add `ioredis` (or use `@redis/client` — prefer `ioredis` for its
robust reconnection handling)
- [ ] Any callers of old `presignCache` — update import if interface changes
### 6. Write `v3/Caddyfile`
- [ ] Global options block: email for ACME, staging CA override for local dev
- [ ] Single site block matching `{$DOMAIN}` (env var injection):
- `handle /api/*``reverse_proxy backend:8080`
- `handle /health``reverse_proxy backend:8080`
- `handle /s3/*` → strip prefix + `reverse_proxy minio:9000` (internal bucket access,
requires `Authorization` passthrough)
- `handle` (catch-all) → `reverse_proxy ui:3000`
- [ ] Health/liveness endpoints exposed without auth
- [ ] No ports on MinIO/PocketBase exposed to public (internal Docker network only)
### 7. Write `v3/docker-compose.yml`
Services (in dependency order):
| Service | Image / Build | Ports (host) | Notes |
|---------|--------------|--------------|-------|
| `minio` | `minio/minio:latest` | none (internal only) | Remove public port exposure; Caddy proxies `/s3/*` |
| `minio-init` | `minio/mc:latest` | — | Same as v2 |
| `pocketbase` | `ghcr.io/muchobien/pocketbase:latest` | none (internal only) | Remove public port |
| `pb-init` | `alpine:3.19` | — | Same as v2, uses `v3/scripts/pb-init-v3.sh` |
| `valkey` | `valkey/valkey:8-alpine` | none (internal) | `valkey-server --save "" --appendonly no` |
| `meilisearch` | `getmeili/meilisearch:v1.7` | none (internal) | `MEILI_NO_ANALYTICS=true`, persistent volume |
| `backend` | build `v3/backend` target `backend` | none (internal) | Add `VALKEY_ADDR`, `MEILI_URL`, `MEILI_API_KEY` |
| `runner` | build `v3/backend` target `runner` | `9091` (metrics) | Add `RUNNER_METRICS_ADDR`, `MEILI_URL`, `MEILI_API_KEY`; healthcheck via `/metrics` |
| `ui` | build `v3/ui` | none (internal) | Add `VALKEY_URL`; remove `PUBLIC_MINIO_PUBLIC_URL` (all through Caddy now) |
| `caddy` | `caddy:2-alpine` | `80`, `443` | Bind-mount `v3/Caddyfile`; persistent `caddy_data` volume for certs |
Volumes: `minio_data`, `pb_data`, `meili_data`, `caddy_data`
- [ ] Write the full file with healthchecks, depends_on, env vars
### 8. Write `v3/scripts/pb-init-v3.sh`
- [ ] Start from `scripts/pb-init-v2.sh`
- [ ] Assess if any schema changes needed (Meilisearch sync does not require new PB collections
— search is entirely Meilisearch-side)
- [ ] If identical to v2 script, note that in a comment at the top; keep as separate file
---
## Environment variables (new in v3)
| Variable | Service | Default | Description |
|----------|---------|---------|-------------|
| `VALKEY_ADDR` | backend | `valkey:6379` | Valkey TCP address |
| `VALKEY_URL` | ui | `redis://valkey:6379` | Valkey URL (ioredis format) |
| `MEILI_URL` | backend, runner | `http://meilisearch:7700` | Meilisearch HTTP URL |
| `MEILI_API_KEY` | backend, runner | `""` | Meilisearch master key (empty = no auth for dev) |
| `RUNNER_METRICS_ADDR` | runner | `:9091` | Runner metrics HTTP listen address |
| `DOMAIN` | caddy | `localhost` | Public domain for HTTPS cert |
| `CADDY_ACME_EMAIL` | caddy | `""` | Let's Encrypt notification email |
---
## Testing checklist (before considering v3 stable)
- [ ] `docker compose -f v3/docker-compose.yml up --build` starts cleanly
- [ ] `curl https://{DOMAIN}/health` returns 200 (Caddy → backend)
- [ ] Book search returns results from Meilisearch after at least one scrape
- [ ] Presign URL cache hit visible in backend logs (Valkey `GET` hit)
- [ ] `curl http://localhost:9091/metrics` returns JSON with counters
- [ ] Runner healthcheck passes via `/metrics` endpoint (not file-based)
- [ ] MinIO console NOT reachable from the internet (no public port)
- [ ] PocketBase NOT reachable from the internet (no public port)
- [ ] TLS cert obtained from Let's Encrypt (check `caddy_data` volume / Caddy logs)

13
v3/backend/.dockerignore Normal file
View File

@@ -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

42
v3/backend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -0,0 +1,139 @@
// 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/meili"
"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{}
}
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
var searchIndex meili.Client
if cfg.Meilisearch.URL != "" {
searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey)
log.Info("meilisearch search enabled", "url", cfg.Meilisearch.URL)
} else {
log.Info("MEILI_URL not set — search will use PocketBase substring fallback")
searchIndex = meili.NoopClient{}
}
// ── 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,
BrowseStore: store,
CoverStore: store,
Producer: store,
TaskReader: store,
SearchIndex: searchIndex,
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
}

View File

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

View File

@@ -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 <url>
// Performs GET <url>; exits 0 if HTTP 2xx/3xx, 1 otherwise.
// Example: /healthcheck http://localhost:8080/health
//
// 2. File-liveness mode:
// /healthcheck file <path> <max_age_seconds>
// Reads <path>, parses its content as RFC3339 timestamp, and exits 1 if the
// timestamp is older than <max_age_seconds>. 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 <path> <max_age_seconds>
func checkFile() {
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "healthcheck file: usage: /healthcheck file <path> <max_age_seconds>")
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)
}
}

View File

@@ -0,0 +1,159 @@
// 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/meili"
"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,
})
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{}
}
// ── Meilisearch ─────────────────────────────────────────────────────────
var searchIndex meili.Client
if cfg.Meilisearch.URL != "" {
if err := meili.Configure(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey); err != nil {
log.Warn("meilisearch configure failed — search indexing disabled", "err", err)
searchIndex = meili.NoopClient{}
} else {
searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey)
log.Info("meilisearch enabled", "url", cfg.Meilisearch.URL)
}
} else {
log.Info("MEILI_URL not set — search indexing disabled")
searchIndex = meili.NoopClient{}
}
// ── Runner ──────────────────────────────────────────────────────────────
rCfg := runner.Config{
WorkerID: cfg.Runner.WorkerID,
PollInterval: cfg.Runner.PollInterval,
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
OrchestratorWorkers: workers,
MetricsAddr: cfg.Runner.MetricsAddr,
CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval,
}
deps := runner.Dependencies{
Consumer: store,
BookWriter: store,
BookReader: store,
AudioStore: store,
BrowseStore: store,
CoverStore: store,
SearchIndex: searchIndex,
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
}

36
v3/backend/go.mod Normal file
View File

@@ -0,0 +1,36 @@
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/andybalholm/brotli v1.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/meilisearch/meilisearch-go v0.36.1 // 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/redis/go-redis/v9 v9.18.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
go.uber.org/atomic v1.11.0 // 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
)

61
v3/backend/go.sum Normal file
View File

@@ -0,0 +1,61 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
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/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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=

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
// 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/meili"
"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
// BrowseStore reads cached browse page snapshots from MinIO.
BrowseStore bookstore.BrowseStore
// CoverStore reads and writes book cover images from MinIO.
// If nil, the cover endpoint falls back to a CDN redirect.
CoverStore bookstore.CoverStore
// Producer creates scrape/audio tasks in PocketBase.
Producer taskqueue.Producer
// TaskReader reads scrape/audio task records from PocketBase.
TaskReader taskqueue.Reader
// SearchIndex provides full-text book search via Meilisearch.
// If nil, the local-only fallback search is used.
SearchIndex meili.Client
// 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()
}
if deps.SearchIndex == nil {
deps.SearchIndex = meili.NoopClient{}
}
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)
// Catalogue (Meilisearch-backed browse + search — preferred path for UI)
mux.HandleFunc("GET /api/catalogue", s.handleCatalogue)
// 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,
})
}

View File

@@ -0,0 +1,151 @@
// 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
}
// BrowseStore covers browse page snapshot storage.
// The runner writes snapshots; the backend reads them.
type BrowseStore interface {
// PutBrowsePage stores a raw JSON snapshot for a browse page.
// genre, sort, status, novelType and page identify the page.
PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error
// GetBrowsePage retrieves a raw JSON snapshot. Returns (nil, false, nil)
// when no snapshot exists for the given parameters.
GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error)
}
// CoverStore covers book cover image storage in MinIO.
// The runner writes covers during catalogue refresh; the backend reads them.
type CoverStore interface {
// PutCover stores a raw cover image for a book identified by slug.
PutCover(ctx context.Context, slug string, data []byte, contentType string) error
// GetCover retrieves the cover image for a book. Returns (nil, false, nil)
// when no cover exists for the given slug.
GetCover(ctx context.Context, slug string) ([]byte, string, bool, error)
// CoverExists returns true when a cover image is stored for slug.
CoverExists(ctx context.Context, slug string) bool
}

View File

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

View File

@@ -0,0 +1,191 @@
// Package browser provides a rate-limited HTTP client for web scraping.
package browser
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"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
}
// 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,
}
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)
}

View File

@@ -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("<html>hello</html>"))
}))
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 != "<html>hello</html>" {
t.Errorf("want <html>hello</html>, 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", "<html>page1</html>")
body, err := stub.GetContent(context.Background(), "http://example.com/page1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body != "<html>page1</html>" {
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)
}
}

View File

@@ -0,0 +1,219 @@
// 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
// BucketBrowse is the bucket that holds cached browse page snapshots (JSON).
BucketBrowse 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
}
// Meilisearch holds connection settings for the Meilisearch full-text search service.
type Meilisearch struct {
// URL is the base URL of the Meilisearch instance, e.g. http://localhost:7700
// An empty string disables Meilisearch indexing and search.
URL string
// APIKey is the Meilisearch master/search API key.
APIKey string
}
// Valkey holds connection settings for the Valkey/Redis presign URL cache.
type Valkey struct {
// Addr is the host:port of the Valkey instance, e.g. localhost:6379
// An empty string disables the Valkey cache (falls through to MinIO directly).
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
// MetricsAddr is the listen address for the runner /metrics HTTP endpoint.
// Defaults to ":9091". Set to "" to disable.
MetricsAddr string
// CatalogueRefreshInterval is how often the runner walks the full catalogue,
// scrapes per-book metadata, downloads covers, and re-indexes in Meilisearch.
// Defaults to 24h. Set to 0 to use the default.
CatalogueRefreshInterval time.Duration
}
// Config is the top-level configuration struct consumed by both binaries.
type Config struct {
PocketBase PocketBase
MinIO MinIO
Kokoro Kokoro
HTTP HTTP
Runner Runner
Meilisearch Meilisearch
Valkey Valkey
// 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"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
},
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),
MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"),
CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0),
},
Meilisearch: Meilisearch{
URL: envOr("MEILI_URL", ""),
APIKey: envOr("MEILI_API_KEY", ""),
},
Valkey: Valkey{
Addr: envOr("VALKEY_ADDR", ""),
},
}
}
// ── 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
}

View File

@@ -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",
}
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)
}
}

View File

@@ -0,0 +1,132 @@
// 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"`
Rating float64 `json:"rating,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"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,257 @@
// Package meili provides a thin Meilisearch client for indexing and searching
// locally scraped books.
//
// Index:
// - Name: "books"
// - Primary key: "slug"
// - Searchable attributes: title, author, genres, summary
// - Filterable attributes: status, genres
// - Sortable attributes: rank, rating, total_chapters
//
// The client is intentionally simple: UpsertBook and Search only. All
// Meilisearch-specific details (index management, attribute configuration)
// are handled once in Configure(), called at startup.
package meili
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/libnovel/backend/internal/domain"
"github.com/meilisearch/meilisearch-go"
)
const indexName = "books"
// Client is the interface for Meilisearch operations used by runner and backend.
type Client interface {
// UpsertBook adds or updates a book document in the search index.
UpsertBook(ctx context.Context, book domain.BookMeta) error
// Search returns up to limit books matching query.
Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
// Catalogue queries books with optional filters, sort, and pagination.
// Returns books and the total hit count for pagination.
Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, error)
}
// CatalogueQuery holds parameters for the /api/catalogue endpoint.
type CatalogueQuery struct {
Q string // full-text query (may be empty for browse)
Genre string // genre filter, e.g. "fantasy" or "all"
Status string // status filter, e.g. "ongoing", "completed", or "all"
Sort string // sort field: "popular", "new", "top-rated", "rank", ""
Page int // 1-indexed
Limit int // items per page, default 20
}
// MeiliClient wraps the meilisearch-go SDK.
type MeiliClient struct {
idx meilisearch.IndexManager
}
// New creates a MeiliClient. Call Configure() once at startup to ensure the
// index exists and has the correct attribute settings.
func New(host, apiKey string) *MeiliClient {
cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))
return &MeiliClient{idx: cli.Index(indexName)}
}
// Configure creates the index if absent and sets searchable/filterable
// attributes. It is idempotent — safe to call on every startup.
func Configure(host, apiKey string) error {
cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))
// Create index with primary key. Returns 202 if exists — ignore.
task, err := cli.CreateIndex(&meilisearch.IndexConfig{
Uid: indexName,
PrimaryKey: "slug",
})
if err != nil {
// 400 "index_already_exists" is not an error here; the SDK returns
// an error with Code "index_already_exists" which we can ignore.
// Any other error is fatal.
if apiErr, ok := err.(*meilisearch.Error); ok && apiErr.MeilisearchApiError.Code == "index_already_exists" {
// already exists — continue
} else {
return fmt.Errorf("meili: create index: %w", err)
}
} else {
_ = task // task is async; we don't wait for it
}
idx := cli.Index(indexName)
searchable := []string{"title", "author", "genres", "summary"}
if _, err := idx.UpdateSearchableAttributes(&searchable); err != nil {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
sortable := []string{"rank", "rating", "total_chapters"}
if _, err := idx.UpdateSortableAttributes(&sortable); err != nil {
return fmt.Errorf("meili: update sortable attributes: %w", err)
}
return nil
}
// bookDoc is the Meilisearch document shape for a book.
type bookDoc struct {
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"`
Rank int `json:"rank"`
Rating float64 `json:"rating"`
}
func toDoc(b domain.BookMeta) bookDoc {
return bookDoc{
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,
Rank: b.Ranking,
Rating: b.Rating,
}
}
func fromDoc(d bookDoc) domain.BookMeta {
return domain.BookMeta{
Slug: d.Slug,
Title: d.Title,
Author: d.Author,
Cover: d.Cover,
Status: d.Status,
Genres: d.Genres,
Summary: d.Summary,
TotalChapters: d.TotalChapters,
SourceURL: d.SourceURL,
Ranking: d.Rank,
Rating: d.Rating,
}
}
// UpsertBook adds or replaces the book document in Meilisearch. The operation
// is fire-and-forget (Meilisearch processes tasks asynchronously).
func (c *MeiliClient) UpsertBook(_ context.Context, book domain.BookMeta) error {
docs := []bookDoc{toDoc(book)}
pk := "slug"
if _, err := c.idx.AddDocuments(docs, &meilisearch.DocumentOptions{PrimaryKey: &pk}); err != nil {
return fmt.Errorf("meili: upsert book %q: %w", book.Slug, err)
}
return nil
}
// Search returns books matching query, up to limit results.
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
if limit <= 0 {
limit = 20
}
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
Limit: int64(limit),
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
}
books := make([]domain.BookMeta, 0, len(res.Hits))
for _, hit := range res.Hits {
// Hit is map[string]json.RawMessage — unmarshal directly into bookDoc.
var doc bookDoc
raw, err := json.Marshal(hit)
if err != nil {
continue
}
if err := json.Unmarshal(raw, &doc); err != nil {
continue
}
books = append(books, fromDoc(doc))
}
return books, nil
}
// Catalogue queries books with optional full-text search, genre/status filters,
// sort order, and pagination. Returns matching books and the total estimate.
func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, error) {
if q.Limit <= 0 {
q.Limit = 20
}
if q.Page <= 0 {
q.Page = 1
}
req := &meilisearch.SearchRequest{
Limit: int64(q.Limit),
Offset: int64((q.Page - 1) * q.Limit),
}
// Build filter
var filters []string
if q.Genre != "" && q.Genre != "all" {
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
}
if q.Status != "" && q.Status != "all" {
filters = append(filters, fmt.Sprintf("status = %q", q.Status))
}
if len(filters) > 0 {
req.Filter = strings.Join(filters, " AND ")
}
// Map UI sort tokens to Meilisearch sort expressions
switch q.Sort {
case "rank":
req.Sort = []string{"rank:asc"}
case "top-rated":
req.Sort = []string{"rating:desc"}
case "new":
req.Sort = []string{"total_chapters:desc"}
// "popular" and "" → relevance (no explicit sort)
}
res, err := c.idx.Search(q.Q, req)
if err != nil {
return nil, 0, fmt.Errorf("meili: catalogue query: %w", err)
}
books := make([]domain.BookMeta, 0, len(res.Hits))
for _, hit := range res.Hits {
var doc bookDoc
raw, err := json.Marshal(hit)
if err != nil {
continue
}
if err := json.Unmarshal(raw, &doc); err != nil {
continue
}
books = append(books, fromDoc(doc))
}
return books, res.EstimatedTotalHits, nil
}
// NoopClient is a no-op Client used when Meilisearch is not configured.
type NoopClient struct{}
func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
return nil, nil
}
func (NoopClient) Catalogue(_ context.Context, _ CatalogueQuery) ([]domain.BookMeta, int64, error) {
return nil, 0, nil
}

View File

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

View File

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

View File

@@ -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 "<html>ok</html>", 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 != "<html>ok</html>" {
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
}

View File

@@ -0,0 +1,222 @@
// 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.
// - An optional PostMetadata hook (set in Config) is called after WriteMetadata
// succeeds. The runner uses this to upsert books into Meilisearch.
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
// PostMetadata is an optional hook called with the scraped BookMeta after
// WriteMetadata succeeds. Errors from the hook are logged but not fatal.
// Used by the runner to index books in Meilisearch.
PostMetadata func(ctx context.Context, meta domain.BookMeta)
}
// Orchestrator runs a single-book scrape pipeline.
type Orchestrator struct {
novel scraper.NovelScraper
store bookstore.BookWriter
log *slog.Logger
workers int
postMetadata func(ctx context.Context, meta domain.BookMeta)
}
// 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,
postMetadata: cfg.PostMetadata,
}
}
// 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
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
if o.postMetadata != nil {
o.postMetadata(ctx, meta)
}
}
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
}

View File

@@ -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 37 = 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")
}
}

View File

@@ -0,0 +1,96 @@
// Package presigncache provides a Valkey (Redis-compatible) backed cache for
// MinIO presigned URLs. The backend generates presigned URLs and stores them
// here with a TTL; subsequent requests for the same key return the cached URL
// without re-contacting MinIO.
//
// Design:
// - Cache is intentionally best-effort: Get returns ("", false, nil) on any
// Valkey error, so callers always have a fallback path to regenerate.
// - Set silently drops errors — a miss on the next request is acceptable.
// - TTL should be set shorter than the actual presigned URL lifetime so that
// cached URLs are always valid when served. Recommended: 55 minutes for a
// 1-hour presigned URL.
package presigncache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// Cache is the interface for presign URL caching.
// Implementations must be safe for concurrent use.
type Cache interface {
// Get returns the cached URL for key. ok is false on cache miss or error.
Get(ctx context.Context, key string) (url string, ok bool, err error)
// Set stores url under key with the given TTL.
Set(ctx context.Context, key, url string, ttl time.Duration) error
// Delete removes key from the cache.
Delete(ctx context.Context, key string) error
}
// ValkeyCache is a Cache backed by Valkey / Redis via go-redis.
type ValkeyCache struct {
rdb *redis.Client
}
// New creates a ValkeyCache connecting to addr (e.g. "valkey:6379").
// The connection is not established until the first command; use Ping to
// verify connectivity at startup.
func New(addr string) *ValkeyCache {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
DialTimeout: 2 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
})
return &ValkeyCache{rdb: rdb}
}
// Ping checks connectivity. Call once at startup.
func (c *ValkeyCache) Ping(ctx context.Context) error {
if err := c.rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("presigncache: ping valkey: %w", err)
}
return nil
}
// Get returns (url, true, nil) on hit, ("", false, nil) on miss, and
// ("", false, err) only on unexpected errors (not redis.Nil).
func (c *ValkeyCache) Get(ctx context.Context, key string) (string, bool, error) {
val, err := c.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("presigncache: get %q: %w", key, err)
}
return val, true, nil
}
// Set stores url under key with ttl. Errors are returned but are non-fatal
// for callers — a Set failure means the next request will miss and regenerate.
func (c *ValkeyCache) Set(ctx context.Context, key, url string, ttl time.Duration) error {
if err := c.rdb.Set(ctx, key, url, ttl).Err(); err != nil {
return fmt.Errorf("presigncache: set %q: %w", key, err)
}
return nil
}
// Delete removes key from the cache. It is not an error if the key does not exist.
func (c *ValkeyCache) Delete(ctx context.Context, key string) error {
if err := c.rdb.Del(ctx, key).Err(); err != nil {
return fmt.Errorf("presigncache: delete %q: %w", key, err)
}
return nil
}
// NoopCache is a no-op Cache that always returns a miss. Used when Valkey is
// not configured (e.g. local development without Docker).
type NoopCache struct{}
func (NoopCache) Get(_ context.Context, _ string) (string, bool, error) { return "", false, nil }
func (NoopCache) Set(_ context.Context, _, _ string, _ time.Duration) error { return nil }
func (NoopCache) Delete(_ context.Context, _ string) error { return nil }

View File

@@ -0,0 +1,176 @@
package runner
// browse_refresh.go — independent 6-hour loop that fetches novelfire.net
// browse page snapshots and stores them in MinIO.
//
// Design:
// - Runs on its own ticker (BrowseRefreshInterval, default 6h) inside Run().
// - Fetches page 1 for each combination of the standard genre/sort/status
// filter values and stores the parsed JSON blob in MinIO via BrowseStore.
// - The backend's handleBrowse then serves from MinIO instead of calling
// novelfire.net live, which avoids IP-based rate-limiting on the server.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// browseNovelListing mirrors backend.NovelListing for JSON serialisation.
type browseNovelListing struct {
Slug string `json:"slug"`
Title string `json:"title"`
Cover string `json:"cover"`
URL string `json:"url"`
}
// browseSnapshot is the JSON structure stored in MinIO.
type browseSnapshot struct {
Novels []browseNovelListing `json:"novels"`
Page int `json:"page"`
HasNext bool `json:"hasNext"`
// CachedAt is the UTC time the snapshot was written (ISO 8601).
CachedAt string `json:"cachedAt"`
}
// browseCombos lists the filter combinations to pre-fetch.
// Each entry is (genre, sort, status, novelType).
var browseCombos = []struct{ genre, sort, status, novelType string }{
{"all", "popular", "all", "all-novel"},
{"all", "popular", "ongoing", "all-novel"},
{"all", "popular", "completed", "all-novel"},
{"all", "new", "all", "all-novel"},
{"all", "new", "ongoing", "all-novel"},
{"all", "new", "completed", "all-novel"},
{"all", "top-rated", "all", "all-novel"},
{"all", "top-rated", "ongoing", "all-novel"},
{"all", "top-rated", "completed", "all-novel"},
}
const novelFireBrowseBase = "https://novelfire.net"
// runBrowseRefresh fetches all browse combos from novelfire.net and stores
// the results in MinIO. Errors per-combo are logged but do not abort the
// whole refresh cycle.
func (r *Runner) runBrowseRefresh(ctx context.Context) {
if r.deps.BrowseStore == nil {
r.deps.Log.Warn("runner: browse refresh skipped — BrowseStore not configured")
return
}
log := r.deps.Log.With("op", "browse_refresh")
log.Info("runner: browse refresh starting", "combos", len(browseCombos))
ok, fail := 0, 0
for _, c := range browseCombos {
if ctx.Err() != nil {
break
}
novels, hasNext, err := fetchBrowsePage(ctx, c.genre, c.sort, c.status, c.novelType)
if err != nil {
log.Warn("runner: browse fetch failed",
"genre", c.genre, "sort", c.sort, "status", c.status, "err", err)
fail++
continue
}
snap := browseSnapshot{
Novels: novels,
Page: 1,
HasNext: hasNext,
CachedAt: time.Now().UTC().Format(time.RFC3339),
}
data, _ := json.Marshal(snap)
if err := r.deps.BrowseStore.PutBrowsePage(ctx, c.genre, c.sort, c.status, c.novelType, 1, data); err != nil {
log.Warn("runner: browse put failed",
"genre", c.genre, "sort", c.sort, "status", c.status, "err", err)
fail++
continue
}
ok++
}
log.Info("runner: browse refresh finished", "ok", ok, "failed", fail)
}
// fetchBrowsePage calls novelfire.net and returns a list of novel listings
// plus a hasNext flag. Mirrors the logic in backend/handlers.go.
func fetchBrowsePage(ctx context.Context, genre, sort, status, novelType string) ([]browseNovelListing, bool, error) {
pageURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=1",
novelFireBrowseBase, genre, sort, status, novelType)
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-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.9")
httpClient := &http.Client{Timeout: 45 * time.Second}
resp, err := httpClient.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 for %s", resp.StatusCode, pageURL)
}
return parseBrowseHTML(resp.Body)
}
// parseBrowseHTML parses a novelfire HTML response body. Mirrors parseBrowsePage
// in backend/handlers.go — kept separate to avoid coupling packages.
func parseBrowseHTML(r io.Reader) ([]browseNovelListing, bool, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, false, err
}
body := string(data)
hasNext := strings.Contains(body, `rel="next"`) ||
strings.Contains(body, `aria-label="Next"`) ||
strings.Contains(body, `class="next"`)
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)
var novels []browseNovelListing
seen := make(map[string]bool)
for i, sm := range slugMatches {
slug := sm[1]
if seen[slug] {
continue
}
seen[slug] = true
item := browseNovelListing{
Slug: slug,
URL: novelFireBrowseBase + "/book/" + slug,
}
if i < len(titleMatches) {
item.Title = strings.TrimSpace(titleMatches[i][1])
}
if i < len(coverMatches) {
item.Cover = coverMatches[i][1]
}
if item.Title != "" {
novels = append(novels, item)
}
}
return novels, hasNext, nil
}

View File

@@ -0,0 +1,177 @@
package runner
// catalogue_refresh.go — independent loop that walks the full novelfire.net
// catalogue, scrapes per-book metadata, downloads cover images to MinIO, and
// indexes every book in Meilisearch.
//
// Design:
// - Runs on its own ticker (CatalogueRefreshInterval, default 24h) inside Run().
// - Also fires once on startup.
// - ScrapeCatalogue streams CatalogueEntry values over a channel — we iterate
// and call ScrapeMetadata for each entry.
// - Per-request random jitter (13s) prevents hammering novelfire.net.
// - Cover images are fetched from the URL embedded in BookMeta.Cover and
// stored in MinIO (browse bucket, key: covers/{slug}.jpg).
// - WriteMetadata + UpsertBook are called for every successfully scraped book.
// - Errors for individual books are logged and skipped; the loop continues.
// - The cover URL stored in BookMeta.Cover is rewritten to the internal proxy
// path (/api/cover/novelfire.net/{slug}) so the UI always fetches via the
// backend, which will serve from MinIO.
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"time"
)
// runCatalogueRefresh performs one full catalogue walk: scrapes metadata for
// every book on novelfire.net, downloads covers to MinIO, and upserts to
// Meilisearch. Errors for individual books are logged and skipped.
func (r *Runner) runCatalogueRefresh(ctx context.Context) {
if r.deps.Novel == nil {
r.deps.Log.Warn("runner: catalogue refresh skipped — Novel scraper not configured")
return
}
if r.deps.BookWriter == nil {
r.deps.Log.Warn("runner: catalogue refresh skipped — BookWriter not configured")
return
}
log := r.deps.Log.With("op", "catalogue_refresh")
log.Info("runner: catalogue refresh starting")
entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx)
ok, skipped, errCount := 0, 0, 0
for entry := range entries {
if ctx.Err() != nil {
break
}
// Random jitter between books to avoid rate-limiting.
jitter := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
select {
case <-ctx.Done():
break
case <-time.After(jitter):
}
meta, err := r.deps.Novel.ScrapeMetadata(ctx, entry.URL)
if err != nil {
log.Warn("runner: catalogue refresh: metadata scrape failed",
"url", entry.URL, "err", err)
errCount++
continue
}
// Rewrite cover URL to backend proxy path so UI never hits CDN directly.
originalCover := meta.Cover
meta.Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", meta.Slug)
// Persist to PocketBase.
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
log.Warn("runner: catalogue refresh: WriteMetadata failed",
"slug", meta.Slug, "err", err)
errCount++
continue
}
// Index in Meilisearch.
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
log.Warn("runner: catalogue refresh: UpsertBook failed",
"slug", meta.Slug, "err", err)
// non-fatal — continue
}
// Download and store cover image in MinIO if we have a cover URL
// and a CoverStore is wired in.
if r.deps.CoverStore != nil && originalCover != "" {
if !r.deps.CoverStore.CoverExists(ctx, meta.Slug) {
if err := r.downloadCover(ctx, meta.Slug, originalCover); err != nil {
log.Warn("runner: catalogue refresh: cover download failed",
"slug", meta.Slug, "url", originalCover, "err", err)
// non-fatal
}
}
}
ok++
if ok%100 == 0 {
log.Info("runner: catalogue refresh progress",
"scraped", ok, "errors", errCount)
}
}
if err := <-errCh; err != nil {
log.Warn("runner: catalogue refresh: catalogue stream error", "err", err)
}
log.Info("runner: catalogue refresh finished",
"ok", ok, "skipped", skipped, "errors", errCount)
}
// downloadCover fetches the cover image from coverURL and stores it in MinIO
// under covers/{slug}.jpg. It retries up to 3 times with exponential backoff
// on transient errors (5xx, network failures).
func (r *Runner) downloadCover(ctx context.Context, slug, coverURL string) error {
const maxRetries = 3
delay := 2 * time.Second
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if ctx.Err() != nil {
return ctx.Err()
}
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay *= 2
}
data, err := fetchCoverBytes(ctx, coverURL)
if err != nil {
lastErr = err
continue
}
if err := r.deps.CoverStore.PutCover(ctx, slug, data, ""); err != nil {
return fmt.Errorf("put cover: %w", err)
}
return nil
}
return fmt.Errorf("download cover after %d retries: %w", maxRetries, lastErr)
}
// fetchCoverBytes performs a single HTTP GET for coverURL and returns the body.
func fetchCoverBytes(ctx context.Context, coverURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, coverURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
req.Header.Set("Referer", "https://novelfire.net/")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("upstream %d for %s", resp.StatusCode, coverURL)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, coverURL)
}
return io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5 MiB cap
}

View File

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

View File

@@ -0,0 +1,92 @@
package runner
// metrics.go — lightweight HTTP metrics endpoint for the runner.
//
// GET /metrics returns a JSON document with live task counters and uptime.
// No external dependency (no Prometheus); plain net/http only.
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"time"
)
// metricsServer serves GET /metrics for the runner process.
type metricsServer struct {
addr string
r *Runner
log *slog.Logger
}
func newMetricsServer(addr string, r *Runner, log *slog.Logger) *metricsServer {
return &metricsServer{addr: addr, r: r, log: log}
}
// ListenAndServe starts the HTTP server and blocks until ctx is cancelled or
// a fatal listen error occurs.
func (ms *metricsServer) ListenAndServe(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /metrics", ms.handleMetrics)
mux.HandleFunc("GET /health", ms.handleHealth)
srv := &http.Server{
Addr: ms.addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
BaseContext: func(_ net.Listener) context.Context { return ctx },
}
errCh := make(chan error, 1)
go func() {
ms.log.Info("runner: metrics server listening", "addr", ms.addr)
errCh <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
return nil
case err := <-errCh:
return fmt.Errorf("runner: metrics server: %w", err)
}
}
// handleMetrics handles GET /metrics.
// Response shape (JSON):
//
// {
// "tasks_running": N,
// "tasks_completed": N,
// "tasks_failed": N,
// "uptime_seconds": N
// }
func (ms *metricsServer) handleMetrics(w http.ResponseWriter, _ *http.Request) {
uptimeSec := int64(time.Since(ms.r.startedAt).Seconds())
metricsWriteJSON(w, 0, map[string]int64{
"tasks_running": ms.r.tasksRunning.Load(),
"tasks_completed": ms.r.tasksCompleted.Load(),
"tasks_failed": ms.r.tasksFailed.Load(),
"uptime_seconds": uptimeSec,
})
}
// handleHealth handles GET /health — simple liveness probe for the metrics server.
func (ms *metricsServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
metricsWriteJSON(w, 0, map[string]string{"status": "ok"})
}
// metricsWriteJSON writes v as a JSON response with the given status code.
func metricsWriteJSON(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)
}

View File

@@ -0,0 +1,440 @@
// 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.
// - Atomic task counters are exposed via /metrics (see metrics.go).
// - Books are indexed in Meilisearch via an orchestrator.Config.PostMetadata
// hook injected at construction time.
package runner
import (
"context"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
"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
// BrowseRefreshInterval is how often the runner pre-fetches browse page
// snapshots from novelfire.net and stores them in MinIO. Defaults to 6h.
BrowseRefreshInterval time.Duration
// CatalogueRefreshInterval is how often the runner walks the full catalogue,
// scrapes per-book metadata, downloads covers, and re-indexes everything in
// Meilisearch. Defaults to 24h (expensive — full catalogue walk).
CatalogueRefreshInterval time.Duration
// MetricsAddr is the HTTP listen address for the /metrics endpoint.
// Defaults to ":9091". Set to "" to disable.
MetricsAddr string
}
// 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
// BrowseStore stores browse page snapshots in MinIO.
BrowseStore bookstore.BrowseStore
// CoverStore stores book cover images in MinIO.
// If nil, cover downloads are skipped during catalogue refresh.
CoverStore bookstore.CoverStore
// SearchIndex indexes books in Meilisearch after scraping.
// If nil a no-op is used.
SearchIndex meili.Client
// 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
// Atomic task counters — read by /metrics without locking.
tasksRunning atomic.Int64
tasksCompleted atomic.Int64
tasksFailed atomic.Int64
startedAt time.Time
}
// New creates a Runner from cfg and deps.
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 cfg.BrowseRefreshInterval <= 0 {
cfg.BrowseRefreshInterval = 6 * time.Hour
}
if cfg.CatalogueRefreshInterval <= 0 {
cfg.CatalogueRefreshInterval = 24 * time.Hour
}
if cfg.MetricsAddr == "" {
cfg.MetricsAddr = ":9091"
}
if deps.Log == nil {
deps.Log = slog.Default()
}
if deps.SearchIndex == nil {
deps.SearchIndex = meili.NoopClient{}
}
return &Runner{cfg: cfg, deps: deps, startedAt: time.Now()}
}
// Run starts the poll loop and the metrics HTTP server, blocking until ctx is
// cancelled.
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,
"browse_refresh_interval", r.cfg.BrowseRefreshInterval,
"catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval,
"metrics_addr", r.cfg.MetricsAddr,
)
// Start metrics HTTP server in background if configured.
if r.cfg.MetricsAddr != "" {
ms := newMetricsServer(r.cfg.MetricsAddr, r, r.deps.Log)
go func() {
if err := ms.ListenAndServe(ctx); err != nil {
r.deps.Log.Error("runner: metrics server error", "err", err)
}
}()
}
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
var wg sync.WaitGroup
tick := time.NewTicker(r.cfg.PollInterval)
defer tick.Stop()
browseTick := time.NewTicker(r.cfg.BrowseRefreshInterval)
defer browseTick.Stop()
catalogueTick := time.NewTicker(r.cfg.CatalogueRefreshInterval)
defer catalogueTick.Stop()
// Run one browse refresh immediately on startup.
go r.runBrowseRefresh(ctx)
// Run one catalogue refresh immediately on startup.
go r.runCatalogueRefresh(ctx)
// Run one poll immediately on startup, then on each tick.
for {
r.poll(ctx, scrapeSem, audioSem, &wg)
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 <-browseTick.C:
go r.runBrowseRefresh(ctx)
case <-catalogueTick.C:
go r.runCatalogueRefresh(ctx)
case <-tick.C:
}
}
}
// poll claims all available pending tasks and dispatches them to goroutines.
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
}
select {
case scrapeSem <- struct{}{}:
default:
r.deps.Log.Warn("runner: scrape semaphore full, will retry next tick",
"task_id", task.ID)
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.ScrapeTask) {
defer wg.Done()
defer func() { <-scrapeSem }()
defer r.tasksRunning.Add(-1)
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
}
select {
case audioSem <- struct{}{}:
default:
r.deps.Log.Warn("runner: audio semaphore full, will retry next tick",
"task_id", task.ID)
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.AudioTask) {
defer wg.Done()
defer func() { <-audioSem }()
defer r.tasksRunning.Add(-1)
r.runAudioTask(ctx, t)
}(task)
}
}
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.
func (r *Runner) newOrchestrator() *orchestrator.Orchestrator {
oCfg := orchestrator.Config{
Workers: r.cfg.OrchestratorWorkers,
PostMetadata: func(ctx context.Context, meta domain.BookMeta) {
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
r.deps.Log.Warn("runner: meilisearch upsert failed",
"slug", meta.Slug, "err", err)
}
},
}
return orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log)
}
// 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")
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)
}
}
}
}()
o := r.newOrchestrator()
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)
}
if result.ErrorMessage != "" {
r.tasksFailed.Add(1)
} else {
r.tasksCompleted.Add(1)
}
log.Info("runner: scrape task finished",
"scraped", result.ChaptersScraped,
"skipped", result.ChaptersSkipped,
"errors", result.Errors,
)
}
// runCatalogueTask runs a full catalogue scrape.
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.
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")
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)
r.tasksFailed.Add(1)
result := domain.AudioResult{ErrorMessage: msg}
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishAudioTask failed", "err", err)
}
}
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
}
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
}
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
}
r.tasksCompleted.Add(1)
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)
}

View File

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

View File

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

View File

@@ -0,0 +1,270 @@
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
bucketBrowse 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,
bucketBrowse: cfg.BucketBrowse,
}, 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, m.bucketBrowse} {
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)
}
// BrowseObjectKey returns the MinIO object key for a cached browse page snapshot.
// Format: browse/{genre}/{sort}/{status}/{type}/page-{n}.json
func BrowseObjectKey(genre, sort, status, novelType string, page int) string {
return fmt.Sprintf("browse/%s/%s/%s/%s/page-%d.json", genre, sort, status, novelType, page)
}
// CoverObjectKey returns the MinIO object key for a book cover image.
// Format: covers/{slug}.jpg
func CoverObjectKey(slug string) string {
return fmt.Sprintf("covers/%s.jpg", slug)
}
// 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
}
// ── Browse operations ─────────────────────────────────────────────────────────
// putBrowse stores raw JSON bytes for a browse page snapshot.
func (m *minioClient) putBrowse(ctx context.Context, key string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, "application/json", data)
}
// getBrowse retrieves a browse page snapshot. Returns (nil, false, nil) when
// the object does not exist.
func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool, error) {
if !m.objectExists(ctx, m.bucketBrowse, key) {
return nil, false, nil
}
data, err := m.getObject(ctx, m.bucketBrowse, key)
if err != nil {
return nil, false, err
}
return data, true, nil
}
// ── Cover operations ──────────────────────────────────────────────────────────
// putCover stores a raw cover image in the browse bucket under covers/{slug}.jpg.
func (m *minioClient) putCover(ctx context.Context, key, contentType string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, contentType, data)
}
// getCover retrieves a cover image. Returns (nil, "", false, nil) when the
// object does not exist.
func (m *minioClient) getCover(ctx context.Context, key string) ([]byte, bool, error) {
if !m.objectExists(ctx, m.bucketBrowse, key) {
return nil, false, nil
}
data, err := m.getObject(ctx, m.bucketBrowse, key)
if err != nil {
return nil, false, err
}
return data, true, nil
}
// coverExists returns true when the cover image object exists.
func (m *minioClient) coverExists(ctx context.Context, key string) bool {
return m.objectExists(ctx, m.bucketBrowse, key)
}
// coverContentType inspects the first bytes of data to determine if it is
// a JPEG or PNG image. Falls back to "image/jpeg".
func coverContentType(data []byte) string {
if len(data) >= 4 {
// PNG magic: 0x89 0x50 0x4E 0x47
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// WebP: starts with "RIFF" at 0..3 and "WEBP" at 8..11
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
return "image/webp"
}
}
return "image/jpeg"
}

View File

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

View File

@@ -0,0 +1,824 @@
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 _ bookstore.BrowseStore = (*Store)(nil)
var _ bookstore.CoverStore = (*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,
"rating": meta.Rating,
}
// 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"`
Rating float64 `json:"rating"`
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,
Rating: b.Rating,
}
}
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 null OR heartbeat_at < threshold).
// PocketBase datetime fields require `=null` not `=""` in filter expressions.
filter := fmt.Sprintf(`status="running"&&(heartbeat_at=null||heartbeat_at<"%s")`, threshold)
resetPayload := map[string]any{
"status": string(domain.TaskStatusPending),
"worker_id": "",
"heartbeat_at": nil,
}
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
}
// ── BrowseStore ────────────────────────────────────────────────────────────────
func (s *Store) PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error {
key := BrowseObjectKey(genre, sort, status, novelType, page)
if err := s.mc.putBrowse(ctx, key, data); err != nil {
return fmt.Errorf("PutBrowsePage: %w", err)
}
return nil
}
func (s *Store) GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error) {
key := BrowseObjectKey(genre, sort, status, novelType, page)
data, ok, err := s.mc.getBrowse(ctx, key)
if err != nil {
return nil, false, fmt.Errorf("GetBrowsePage: %w", err)
}
return data, ok, nil
}
// ── CoverStore ─────────────────────────────────────────────────────────────────
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
key := CoverObjectKey(slug)
if contentType == "" {
contentType = coverContentType(data)
}
if err := s.mc.putCover(ctx, key, contentType, data); err != nil {
return fmt.Errorf("PutCover: %w", err)
}
return nil
}
func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) {
key := CoverObjectKey(slug)
data, ok, err := s.mc.getCover(ctx, key)
if err != nil {
return nil, "", false, fmt.Errorf("GetCover: %w", err)
}
if !ok {
return nil, "", false, nil
}
ct := coverContentType(data)
return data, ct, true, nil
}
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug))
}

View File

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

View File

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

301
v3/backend/todos.md Normal file
View File

@@ -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=<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 |

277
v3/docker-compose.yml Normal file
View File

@@ -0,0 +1,277 @@
# ── Shared environment fragments ──────────────────────────────────────────────
# These YAML anchors eliminate duplication between backend and runner.
# Both services talk to the same internal MinIO / PocketBase / Meilisearch /
# Valkey endpoints; only service-specific vars differ.
x-infra-env: &infra-env
# MinIO
MINIO_ENDPOINT: "minio:9000"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}"
MINIO_USE_SSL: "false"
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
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}"
# Meilisearch
MEILI_URL: "http://meilisearch:7700"
MEILI_API_KEY: "${MEILI_MASTER_KEY:-changeme_meili_123}"
# Valkey
VALKEY_ADDR: "valkey:6379"
services:
# ─── MinIO (object storage: chapters, audio, avatars, browse) ────────────────
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}"
# No public port — all presigned URL traffic goes through backend or a
# separately-exposed MINIO_PUBLIC_ENDPOINT (e.g. storage.libnovel.cc).
expose:
- "9000"
- "9001"
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;
mc mb --ignore-existing local/libnovel-browse;
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}"
# No public port — accessed only by backend/runner on the internal network.
expose:
- "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-v3.sh:/pb-init.sh:ro
entrypoint: ["sh", "/pb-init.sh"]
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
meilisearch:
image: getmeili/meilisearch:latest
restart: unless-stopped
environment:
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY:-changeme_meili_123}"
MEILI_ENV: "${MEILI_ENV:-production}"
# No public port — backend/runner reach it via internal network.
expose:
- "7700"
volumes:
- meili_data:/meili_data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7700/health"]
interval: 10s
timeout: 5s
retries: 5
# ─── Valkey (presign URL cache) ───────────────────────────────────────────────
valkey:
image: valkey/valkey:7-alpine
restart: unless-stopped
# No public port — backend/runner/ui reach it via internal network.
expose:
- "6379"
volumes:
- valkey_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ─── 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
meilisearch:
condition: service_healthy
valkey:
condition: service_healthy
# No public port — all traffic is routed via Caddy.
expose:
- "8080"
environment:
<<: *infra-env
BACKEND_HTTP_ADDR: ":8080"
LOG_LEVEL: "${LOG_LEVEL:-info}"
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
meilisearch:
condition: service_healthy
valkey:
condition: service_healthy
# Metrics endpoint — internal only; expose publicly via Caddy if needed.
expose:
- "9091"
environment:
<<: *infra-env
LOG_LEVEL: "${LOG_LEVEL:-info}"
# Runner tuning
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL:-30s}"
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_TIMEOUT: "${RUNNER_TIMEOUT:-90s}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR:-:9091}"
# Kokoro-FastAPI TTS endpoint
KOKORO_URL: "${KOKORO_URL:-}"
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
healthcheck:
# The runner 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
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
valkey:
condition: service_healthy
# No public port — all traffic via Caddy.
expose:
- "3000"
environment:
# ORIGIN must match the public URL Caddy serves on.
# adapter-node uses this for SvelteKit's built-in CSRF origin check.
ORIGIN: "${ORIGIN:-https://${DOMAIN:-localhost}}"
BACKEND_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}"
# Valkey
VALKEY_ADDR: "valkey:6379"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 3
# ─── Caddy (reverse proxy + automatic HTTPS) ──────────────────────────────────
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ui:
condition: service_healthy
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
environment:
DOMAIN: "${DOMAIN:-localhost}"
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL:-}"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
minio_data:
pb_data:
meili_data:
valkey_data:
caddy_data:
caddy_config:

82
v3/docs/api-endpoints.md Normal file
View File

@@ -0,0 +1,82 @@
# API Endpoint Reference
All endpoints served by the Go **backend** binary on `:8080`. In production all
traffic is routed through Caddy — `/api/*` and `/health` are proxied to the
backend; everything else goes to the SvelteKit UI.
## Health / Version
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/health` | — | Liveness probe. Returns `{"ok":true}`. |
| `GET` | `/api/version` | — | Build version + commit hash. |
## Scrape Jobs (admin)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/scrape` | admin | Enqueue full catalogue scrape. |
| `POST` | `/scrape/book` | admin | Enqueue single-book scrape `{url}`. |
| `POST` | `/scrape/book/range` | admin | Enqueue range scrape `{url, from, to?}`. |
| `GET` | `/api/scrape/status` | admin | Current job status. |
| `GET` | `/api/scrape/tasks` | admin | All scrape task records. |
| `POST` | `/api/cancel-task/{id}` | admin | Cancel a pending task. |
## Browse / Catalogue
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/browse` | — | Live novelfire.net browse (MinIO page-1 cache). Legacy — used by save-browse subcommand. |
| `GET` | `/api/catalogue` | — | **Primary browse endpoint.** Meilisearch-backed, paginated. Params: `q`, `page`, `limit`, `genre`, `status`, `sort` (`popular`\|`new`\|`update`\|`rank`\|`top-rated`). Falls back to empty when Meilisearch is not configured. |
| `GET` | `/api/search` | — | Full-text search: Meilisearch local results merged with live novelfire.net remote results. Param: `q` (≥ 2 chars). Used by iOS app. |
| `GET` | `/api/ranking` | — | Top-ranked novels from PocketBase. |
| `GET` | `/api/cover/{domain}/{slug}` | — | Proxy cover image from MinIO (redirect to presigned URL). |
## Book / Chapter Content
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/book-preview/{slug}` | — | Returns stored metadata + chapter list, or enqueues a scrape task (202) if unknown. |
| `GET` | `/api/chapter-text/{slug}/{n}` | — | Chapter content as plain text (markdown stripped). |
| `GET` | `/api/chapter-markdown/{slug}/{n}` | — | Chapter content as raw markdown from MinIO. |
| `POST` | `/api/reindex/{slug}` | admin | Rebuild `chapters_idx` from MinIO objects. |
## Audio
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/audio/{slug}/{n}` | — | Trigger Kokoro TTS generation. Body: `{voice?}`. Returns `200 {status:"done"}` if cached, `202 {task_id, status}` if enqueued. |
| `GET` | `/api/audio/status/{slug}/{n}` | — | Poll audio generation status. Param: `voice`. Returns `{status, task_id?, error?}`. |
| `GET` | `/api/audio-proxy/{slug}/{n}` | — | Redirect to presigned MinIO audio URL. |
| `GET` | `/api/voices` | — | List available Kokoro voices. Returns `{voices:[]}` on error. |
## Presigned URLs
All presign endpoints return a `302` redirect to a short-lived MinIO presigned
URL. The URL is cached in Valkey (TTL ~55 min) to avoid regenerating on every
request.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/presign/chapter/{slug}/{n}` | — | Presigned URL for chapter markdown object. |
| `GET` | `/api/presign/audio/{slug}/{n}` | — | Presigned URL for audio MP3. Param: `voice`. |
| `GET` | `/api/presign/voice-sample/{voice}` | — | Presigned URL for voice sample MP3. |
| `GET` | `/api/presign/avatar-upload/{userId}` | user | Presigned PUT URL for avatar upload. |
| `GET` | `/api/presign/avatar/{userId}` | — | Presigned GET URL for avatar image. |
## Reading Progress
Session-scoped (anonymous via cookie session ID, or tied to authenticated user).
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/progress` | — | Get all reading progress for the current session/user. |
| `POST` | `/api/progress/{slug}` | — | Set progress. Body: `{chapter}`. |
| `DELETE` | `/api/progress/{slug}` | — | Delete progress for a book. |
## Notes
- **Auth**: The backend does not enforce auth itself — the SvelteKit UI layer enforces admin/user guards before proxying requests. The backend trusts all incoming requests.
- **`/api/catalogue` vs `/api/browse`**: `/api/catalogue` is the primary UI endpoint (Meilisearch, always-local, fast). `/api/browse` hits or caches the live novelfire.net browse page and is only used internally by the `save-browse` subcommand.
- **Meilisearch fallback**: When `MEILI_URL` is unset, `/api/catalogue` returns `{books:[], has_next:false}` and `/api/search` falls back to a PocketBase substring scan.
- **`BACKEND_API_URL`**: The SvelteKit UI reads this env var (default `http://localhost:8080`) to reach the backend server-side. In docker-compose it is set to `http://backend:8080`.

129
v3/docs/architecture.d2 Normal file
View File

@@ -0,0 +1,129 @@
direction: right
# ─── External ─────────────────────────────────────────────────────────────────
novelfire: novelfire.net {
shape: cloud
style.fill: "#f0f4ff"
}
kokoro: Kokoro-FastAPI TTS {
shape: cloud
style.fill: "#f0f4ff"
}
letsencrypt: Let's Encrypt {
shape: cloud
style.fill: "#f0f4ff"
}
browser: Browser / iOS App {
shape: person
style.fill: "#fff9e6"
}
# ─── Init containers (one-shot) ───────────────────────────────────────────────
init: Init containers {
style.fill: "#f5f5f5"
style.stroke-dash: 4
minio-init: minio-init {
shape: rectangle
label: "minio-init\n(mc: create buckets)"
}
pb-init: pb-init {
shape: rectangle
label: "pb-init\n(bootstrap collections)"
}
}
# ─── Storage ──────────────────────────────────────────────────────────────────
storage: Storage {
style.fill: "#eaf7ea"
minio: MinIO {
shape: cylinder
label: "MinIO :9000\n\nbuckets:\n libnovel-chapters\n libnovel-audio\n libnovel-avatars\n libnovel-browse"
}
pocketbase: PocketBase {
shape: cylinder
label: "PocketBase :8090\n\ncollections:\n books chapters_idx\n audio_cache progress\n scrape_jobs app_users\n ranking"
}
valkey: Valkey {
shape: cylinder
label: "Valkey :6379\n\n(presign URL cache\nTTL-based, shared)"
}
meilisearch: Meilisearch {
shape: cylinder
label: "Meilisearch :7700\n\nindices:\n books"
}
}
# ─── Application ──────────────────────────────────────────────────────────────
app: Application {
style.fill: "#eef3ff"
caddy: caddy {
shape: rectangle
label: "Caddy :443 / :80\n(reverse proxy\nauto-HTTPS via Let's Encrypt)"
}
backend: backend {
shape: rectangle
label: "Backend API :8080\n(Go — HTTP API server)"
}
runner: runner {
shape: rectangle
label: "Runner :9091\n(Go — background worker\nscraping + TTS jobs\n/metrics endpoint)"
}
ui: ui {
shape: rectangle
label: "SvelteKit UI :3000\n(adapter-node)"
}
}
# ─── Init → Storage deps ──────────────────────────────────────────────────────
init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4}
init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4}
# ─── App → Storage ────────────────────────────────────────────────────────────
app.backend -> storage.minio: blobs (chapters, audio,\navatars, browse)
app.backend -> storage.pocketbase: structured records\n(books, progress, jobs…)
app.backend -> storage.valkey: cache presigned URLs\n(SET/GET with TTL)
app.runner -> storage.minio: write chapter markdown\n& audio MP3s
app.runner -> storage.pocketbase: read/update scrape jobs\nwrite book records
app.runner -> storage.meilisearch: index books on\nscrape completion
app.ui -> storage.valkey: read presigned URL cache\n(replaces in-process Map)
# ─── App internal ─────────────────────────────────────────────────────────────
app.ui -> app.backend: REST API calls\n(server-side)
# ─── Caddy routing ────────────────────────────────────────────────────────────
app.caddy -> app.ui: /* (proxy)
app.caddy -> app.backend: /api/*, /health (proxy)
app.caddy -> storage.minio: /s3/* (proxy, internal only)
# ─── External → App ───────────────────────────────────────────────────────────
app.runner -> novelfire: scrape\n(HTTP GET)
app.runner -> kokoro: TTS generation\n(HTTP POST)
app.caddy -> letsencrypt: ACME certificate\n(TLS-ALPN-01)
# ─── Browser ──────────────────────────────────────────────────────────────────
browser -> app.caddy: HTTPS :443\n(single entry point)

View File

@@ -0,0 +1,59 @@
```mermaid
graph LR
%% ── External ──────────────────────────────────────────────────────────
NF([novelfire.net])
KK([Kokoro-FastAPI TTS])
LE([Let's Encrypt])
CL([Browser / iOS App])
%% ── Init containers ───────────────────────────────────────────────────
subgraph INIT["Init containers (one-shot)"]
MI[minio-init\nmc: create buckets]
PI[pb-init\nbootstrap collections]
end
%% ── Storage ───────────────────────────────────────────────────────────
subgraph STORAGE["Storage"]
MN[(MinIO :9000\nchapters · audio\navatars · browse)]
PB[(PocketBase :8090\nbooks · chapters_idx\naudio_cache · progress\nscrape_jobs · app_users · ranking)]
VK[(Valkey :6379\npresign URL cache\nTTL-based · shared)]
MS[(Meilisearch :7700\nindex: books)]
end
%% ── Application ───────────────────────────────────────────────────────
subgraph APP["Application"]
CD[Caddy :443/:80\nreverse proxy\nauto-HTTPS]
BE[Backend API :8080\nGo HTTP server]
RN[Runner :9091\nGo background worker\n/metrics endpoint]
UI[SvelteKit UI :3000\nadapter-node]
end
%% ── Init → Storage ────────────────────────────────────────────────────
MI -.->|create buckets| MN
PI -.->|bootstrap schema| PB
%% ── App → Storage ─────────────────────────────────────────────────────
BE -->|blobs| MN
BE -->|structured records| PB
BE -->|cache presigned URLs| VK
RN -->|chapter markdown & audio| MN
RN -->|read/update jobs & books| PB
RN -->|index books on scrape| MS
UI -->|read presign cache| VK
%% ── App internal ──────────────────────────────────────────────────────
UI -->|REST API| BE
%% ── Caddy routing ─────────────────────────────────────────────────────
CD -->|/* proxy| UI
CD -->|/api/* /health proxy| BE
CD -->|/s3/* proxy internal| MN
%% ── Runner → External ─────────────────────────────────────────────────
RN -->|scrape HTTP GET| NF
RN -->|TTS HTTP POST| KK
CD -->|ACME certificate| LE
%% ── Client ────────────────────────────────────────────────────────────
CL -->|HTTPS :443 single entry| CD
```

125
v3/docs/architecture.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,102 @@
# Data Flow — Scrape & TTS Job Pipeline
How content moves from novelfire.net through the runner into storage, and how
audio is generated on-demand via the backend.
## Catalogue Scrape Pipeline
The runner performs a background catalogue walk on startup and then on a
configurable interval (`RUNNER_CATALOGUE_REFRESH_INTERVAL`, default 24 h).
```mermaid
flowchart TD
A([Runner starts / refresh tick]) --> B[Walk novelfire.net catalogue\npages 1…N]
B --> C{Book already\nin PocketBase?}
C -- no --> D[Scrape book metadata\ntitle · author · genres\ncover · summary · status]
C -- yes --> E[Check for new chapters\ncompare total_chapters]
D --> F[Write BookMeta\nto PocketBase books]
E --> G{New chapters\nfound?}
G -- no --> Z([Done — next book])
G -- yes --> H
F --> H[Scrape chapter list\n→ chapters_idx in PocketBase]
H --> I[Worker pool — N goroutines\nRUNNER_MAX_CONCURRENT_SCRAPE]
I --> J[For each missing chapter:\nGET chapter HTML from novelfire.net]
J --> K[Parse HTML → Markdown\nhtmlutil.NodeToMarkdown]
K --> L[PUT object to MinIO\nlibnovel-chapters/{slug}/{n}.md]
L --> M[Upsert book doc\nto Meilisearch index: books]
M --> Z
F --> M
```
## On-Demand Single-Book Scrape
Triggered when a user visits `/books/{slug}` and the book is not in PocketBase.
The UI calls `GET /api/book-preview/{slug}` → backend enqueues a task.
```mermaid
sequenceDiagram
actor U as User
participant UI as SvelteKit UI
participant BE as Backend API
participant TQ as Task Queue (PocketBase)
participant RN as Runner
participant NF as novelfire.net
participant PB as PocketBase
participant MN as MinIO
participant MS as Meilisearch
U->>UI: Visit /books/{slug}
UI->>BE: GET /api/book-preview/{slug}
BE->>PB: getBook(slug) — not found
BE->>TQ: INSERT scrape_task (slug, status=pending)
BE-->>UI: 202 {task_id, message}
UI-->>U: "Scraping…" placeholder
RN->>TQ: Poll for pending tasks
TQ-->>RN: scrape_task (slug)
RN->>NF: GET novelfire.net/book/{slug}
NF-->>RN: HTML
RN->>PB: upsert book + chapters_idx
RN->>MN: PUT chapter objects
RN->>MS: UpsertBook doc
RN->>TQ: UPDATE task status=done
U->>UI: Poll GET /api/scrape/tasks/{task_id}
UI->>BE: GET /api/scrape/status
BE->>TQ: get task
TQ-->>BE: status=done
BE-->>UI: {status:"done"}
UI-->>U: Redirect to /books/{slug}
```
## TTS Audio Generation Pipeline
Audio is generated lazily: on first request the job is enqueued; subsequent
requests poll for completion and then stream from MinIO via presigned URL.
```mermaid
flowchart TD
A([POST /api/audio/{slug}/{n}\nbody: voice=af_bella]) --> B{Audio already\nin MinIO?}
B -- yes --> C[200 status: done]
B -- no --> D{Job already\nin queue?}
D -- yes pending/generating --> E[202 task_id + status]
D -- no --> F[INSERT audio_task\nstatus=pending\nin PocketBase]
F --> E
G([Runner polls task queue]) --> H[Claim audio_task\nstatus=generating]
H --> I[GET /api/chapter-text/{slug}/{n}\nfrom backend — plain text]
I --> J[POST /v1/audio/speech\nto Kokoro-FastAPI\nbody: text + voice]
J --> K[Stream MP3 response]
K --> L[PUT object to MinIO\nlibnovel-audio/{slug}/{n}/{voice}.mp3]
L --> M[UPDATE audio_task\nstatus=done]
N([Client polls\nGET /api/audio/status/{slug}/{n}]) --> O{status?}
O -- pending/generating --> N
O -- done --> P[GET /api/presign/audio/{slug}/{n}]
P --> Q{Valkey cache hit?}
Q -- yes --> R[302 → presigned URL]
Q -- no --> S[GeneratePresignedURL\nfrom MinIO — TTL 1h]
S --> T[Cache in Valkey\nTTL 3500s]
T --> R
R --> U([Client streams audio\ndirectly from MinIO])
```

View File

@@ -0,0 +1,87 @@
# Request Flow
Two representative request paths through the stack: a **page load** (SSR) and a
**media playback** (presigned URL → direct MinIO stream).
## SSR Page Load — Browse / Book Detail
```mermaid
sequenceDiagram
actor C as Browser / iOS App
participant CD as Caddy :443
participant UI as SvelteKit UI :3000
participant BE as Backend API :8080
participant MS as Meilisearch :7700
participant PB as PocketBase :8090
participant VK as Valkey :6379
participant MN as MinIO :9000
C->>CD: HTTPS GET /browse
CD->>UI: proxy /*
UI->>BE: GET /api/catalogue?page=1&sort=popular
BE->>MS: search(query, filters, sort)
MS-->>BE: [{slug, title, …}, …]
BE-->>UI: {books[], page, total, has_next}
UI-->>CD: SSR HTML
CD-->>C: 200 HTML
Note over C,UI: Infinite scroll — client fetches next page
C->>CD: HTTPS GET /api/browse-page?page=2
CD->>UI: proxy (SvelteKit API route)
UI->>BE: GET /api/catalogue?page=2
BE->>MS: search(…)
MS-->>BE: next page
BE-->>UI: {books[], …}
UI-->>C: JSON
```
## Audio Playback — Presigned URL Flow
```mermaid
sequenceDiagram
actor C as Browser / iOS App
participant CD as Caddy :443
participant UI as SvelteKit UI :3000
participant BE as Backend API :8080
participant VK as Valkey :6379
participant MN as MinIO :9000
C->>CD: GET /api/presign/audio/{slug}/{n}?voice=af_bella
CD->>BE: proxy /api/*
BE->>VK: GET presign:audio:{slug}:{n}:{voice}
alt cache hit
VK-->>BE: presigned URL (TTL remaining)
BE-->>C: 302 redirect → presigned URL
else cache miss
BE->>MN: GeneratePresignedURL(audio-bucket, key, 1h)
MN-->>BE: presigned URL
BE->>VK: SET presign:audio:… EX 3500
BE-->>C: 302 redirect → presigned URL
end
C->>MN: GET presigned URL (direct, no proxy)
MN-->>C: audio/mpeg stream
```
## Chapter Read — SSR + Content Fetch
```mermaid
sequenceDiagram
actor C as Browser / iOS App
participant CD as Caddy :443
participant UI as SvelteKit UI :3000
participant BE as Backend API :8080
participant PB as PocketBase :8090
participant MN as MinIO :9000
C->>CD: HTTPS GET /books/{slug}/chapters/{n}
CD->>UI: proxy /*
UI->>PB: getBook(slug) + listChapterIdx(slug)
PB-->>UI: book meta + chapter list
UI->>BE: GET /api/chapter-markdown/{slug}/{n}
BE->>MN: GetObject(chapters-bucket, {slug}/{n}.md)
MN-->>BE: markdown text
BE-->>UI: markdown body
Note over UI: marked() → HTML
UI-->>CD: SSR HTML
CD-->>C: 200 HTML
```

423
v3/scripts/e2e-test.mjs Normal file
View File

@@ -0,0 +1,423 @@
#!/usr/bin/env node
/**
* e2e-test.mjs — End-to-end tests for the LibNovel v3 stack.
*
* Hits live services via https://localhost (self-signed cert, TLS verify skipped).
* Requires: Node 18+ (built-in fetch with TLS options via --experimental-fetch or
* native in Node 21+). Run with: node --experimental-vm-modules scripts/e2e-test.mjs
* or simply: node scripts/e2e-test.mjs
*
* Services tested:
* - Caddy / UI https://localhost
* - Go backend via UI proxy routes
* - PocketBase via UI server-side (indirect)
*
* Usage:
* node scripts/e2e-test.mjs
* node scripts/e2e-test.mjs --verbose
*/
import { createServer } from 'node:https';
import { request as httpRequest } from 'node:https';
import { URL } from 'node:url';
const BASE = 'https://localhost';
const VERBOSE = process.argv.includes('--verbose');
// ─── Helpers ──────────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
const failures = [];
function log(...args) {
if (VERBOSE) console.log(...args);
}
function pass(name) {
passed++;
console.log(`${name}`);
}
function fail(name, reason) {
failed++;
const msg = `${name}: ${reason}`;
console.log(msg);
failures.push({ name, reason });
}
/**
* fetch() that ignores TLS certificate errors (self-signed cert on localhost).
*/
async function get(path, { headers = {}, followRedirects = false } = {}) {
const url = path.startsWith('http') ? path : `${BASE}${path}`;
const res = await fetch(url, {
redirect: followRedirects ? 'follow' : 'manual',
headers,
// Node 18/19 uses undici which respects NODE_TLS_REJECT_UNAUTHORIZED
});
return res;
}
async function post(path, body, { headers = {}, cookie = '' } = {}) {
const url = path.startsWith('http') ? path : `${BASE}${path}`;
const res = await fetch(url, {
method: 'POST',
redirect: 'manual',
headers: {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
...headers,
},
body: JSON.stringify(body),
});
return res;
}
async function del(path, { cookie = '' } = {}) {
const url = path.startsWith('http') ? path : `${BASE}${path}`;
const res = await fetch(url, {
method: 'DELETE',
redirect: 'manual',
headers: cookie ? { Cookie: cookie } : {},
});
return res;
}
/** Extract Set-Cookie header value(s) as a single cookie string. */
function extractCookies(res) {
const raw = res.headers.getSetCookie?.() ?? [];
return raw.map((c) => c.split(';')[0]).join('; ');
}
async function assert(name, fn) {
try {
await fn();
pass(name);
} catch (e) {
fail(name, e.message);
}
}
function expect(val, label) {
return {
toBe(expected) {
if (val !== expected) throw new Error(`${label}: expected ${expected}, got ${val}`);
},
toBeOneOf(...options) {
if (!options.includes(val)) throw new Error(`${label}: expected one of [${options.join(', ')}], got ${val}`);
},
toBeOk() {
if (!val) throw new Error(`${label} was falsy`);
},
toContainKey(key) {
if (!(key in val)) throw new Error(`${label}: missing key "${key}"`);
},
toBeArray() {
if (!Array.isArray(val)) throw new Error(`${label}: expected array, got ${typeof val}`);
},
toBeAbove(n) {
if (!(val > n)) throw new Error(`${label}: expected > ${n}, got ${val}`);
},
};
}
// ─── Test suite ───────────────────────────────────────────────────────────────
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// Pick a known book slug from the database (first available)
let TEST_SLUG = null;
console.log('\nLibNovel v3 — End-to-End Tests');
console.log('================================\n');
// ── 1. Health checks ──────────────────────────────────────────────────────────
console.log('1. Health checks');
await assert('UI health endpoint returns 200', async () => {
const res = await get('/health');
expect(res.status, 'status').toBe(200);
});
await assert('Home page returns 200', async () => {
const res = await get('/', { followRedirects: true });
expect(res.status, 'status').toBe(200);
const html = await res.text();
expect(html.includes('<html') || html.includes('<!DOCTYPE'), 'has html').toBe(true);
});
await assert('Browse page returns 200', async () => {
const res = await get('/browse', { followRedirects: true });
expect(res.status, 'status').toBe(200);
});
// ── 2. Home API ───────────────────────────────────────────────────────────────
console.log('\n2. Home API');
let homeData = null;
await assert('GET /api/home returns continue_reading and recently_updated', async () => {
const res = await get('/api/home', { followRedirects: true });
expect(res.status, 'status').toBe(200);
homeData = await res.json();
expect(homeData, 'data').toContainKey('continue_reading');
expect(homeData, 'data').toContainKey('recently_updated');
expect(homeData.continue_reading, 'continue_reading').toBeArray();
expect(homeData.recently_updated, 'recently_updated').toBeArray();
});
await assert('GET /api/home stats has totalBooks and totalChapters', async () => {
if (!homeData) {
const res = await get('/api/home', { followRedirects: true });
homeData = await res.json();
}
expect(homeData, 'data').toContainKey('stats');
expect(typeof homeData.stats.totalBooks, 'totalBooks type').toBe('number');
expect(typeof homeData.stats.totalChapters, 'totalChapters type').toBe('number');
});
// ── 3. Browse / ranking / search ──────────────────────────────────────────────
console.log('\n3. Browse / ranking / search');
await assert('GET /api/browse-page returns novels array', async () => {
const res = await get('/api/browse-page?page=1', { followRedirects: true });
expect(res.status, 'status').toBeOneOf(200, 503);
if (res.status === 200) {
const data = await res.json();
// Accepts { novels: [...] } or { error: ... } (when MinIO cache is empty)
expect(typeof data, 'response type').toBe('object');
}
});
await assert('GET /api/ranking returns array or 502 (no data yet)', async () => {
const res = await get('/api/ranking', { followRedirects: true });
// 200 = ranking data exists; 502 = no ranking data scraped yet — both are expected
expect(res.status, 'status').toBeOneOf(200, 502);
if (res.status === 200) {
const data = await res.json();
expect(data, 'data').toBeArray();
}
});
await assert('GET /api/search?q=shadow returns results object', async () => {
const res = await get('/api/search?q=shadow', { followRedirects: true });
expect(res.status, 'status').toBe(200);
const data = await res.json();
expect(typeof data, 'response type').toBe('object');
});
// ── 4. Books ──────────────────────────────────────────────────────────────────
console.log('\n4. Books');
// Find a real slug from /api/home
await assert('GET /api/home books are accessible', async () => {
if (!homeData) {
const res = await get('/api/home', { followRedirects: true });
homeData = await res.json();
}
const books = homeData?.recently_updated ?? [];
if (books.length > 0) {
TEST_SLUG = books[0].slug;
log(` Using test slug: ${TEST_SLUG}`);
}
// Pass regardless — we just want to find a slug
});
if (TEST_SLUG) {
await assert(`GET /api/book/${TEST_SLUG} returns book metadata`, async () => {
const res = await get(`/api/book/${TEST_SLUG}`, { followRedirects: true });
expect(res.status, 'status').toBe(200);
const data = await res.json();
// Returns { book: { slug, ... }, chapters: [...] }
expect(data, 'data').toContainKey('book');
expect(data.book, 'book').toContainKey('slug');
expect(data.book.slug, 'slug').toBe(TEST_SLUG);
});
await assert(`Book detail page /${TEST_SLUG} returns 200`, async () => {
const res = await get(`/${TEST_SLUG}`, { followRedirects: true });
expect(res.status, 'status').toBe(200);
});
} else {
console.log(' ⚠ No books in database — skipping book-specific tests');
}
// ── 5. Voices ─────────────────────────────────────────────────────────────────
console.log('\n5. Voices');
await assert('GET /api/voices returns voices array', async () => {
const res = await get('/api/voices', { followRedirects: true });
expect(res.status, 'status').toBe(200);
const data = await res.json();
// Returns { voices: [...] }
expect(data, 'data').toContainKey('voices');
expect(data.voices, 'voices').toBeArray();
expect(data.voices.length, 'voice count').toBeAbove(0);
});
// ── 6. Auth flow ──────────────────────────────────────────────────────────────
console.log('\n6. Auth flow');
const TEST_USER = `e2e_test_${Date.now()}`;
const TEST_PASS = 'E2eTestPassword1!';
let authCookie = '';
await assert('POST /api/auth/register creates new user', async () => {
const res = await post('/api/auth/register', { username: TEST_USER, password: TEST_PASS });
expect(res.status, 'status').toBeOneOf(200, 201);
const data = await res.json();
// Returns { token: "...", user: { id, username, role } }
expect(data, 'response').toContainKey('user');
expect(data.user, 'user').toContainKey('username');
expect(data.user.username, 'username').toBe(TEST_USER);
// Build cookie from token
if (data.token) {
authCookie = `libnovel_auth=${data.token}`;
} else {
authCookie = extractCookies(res);
}
log(` Auth cookie: ${authCookie.slice(0, 40)}...`);
});
await assert('GET /api/auth/me returns current user when logged in', async () => {
const res = await get('/api/auth/me', { followRedirects: true, headers: { Cookie: authCookie } });
expect(res.status, 'status').toBe(200);
const data = await res.json();
expect(data, 'data').toContainKey('username');
expect(data.username, 'username').toBe(TEST_USER);
});
await assert('POST /api/auth/logout clears session', async () => {
const res = await post('/api/auth/logout', {}, { cookie: authCookie });
expect(res.status, 'status').toBeOneOf(200, 204);
});
await assert('POST /api/auth/login works after register', async () => {
const res = await post('/api/auth/login', { username: TEST_USER, password: TEST_PASS });
expect(res.status, 'status').toBe(200);
const data = await res.json();
// Returns { token: "...", user: { id, username, role } }
expect(data, 'response').toContainKey('user');
expect(data.user.username, 'username').toBe(TEST_USER);
if (data.token) {
authCookie = `libnovel_auth=${data.token}`;
} else {
authCookie = extractCookies(res);
}
});
await assert('GET /api/auth/me unauthenticated returns 401', async () => {
const res = await get('/api/auth/me', { followRedirects: true });
expect(res.status, 'status').toBe(401);
});
// ── 7. Progress ───────────────────────────────────────────────────────────────
console.log('\n7. Progress');
// /api/progress (root) is POST-only. Per-slug is GET/POST/DELETE via /api/progress/[slug].
if (TEST_SLUG) {
await assert(`POST /api/progress/${TEST_SLUG} sets progress`, async () => {
const res = await post(`/api/progress/${TEST_SLUG}`, { chapter: 1 });
expect(res.status, 'status').toBeOneOf(200, 201);
const data = await res.json();
expect(data, 'data').toContainKey('ok');
});
await assert(`DELETE /api/progress/${TEST_SLUG} removes progress`, async () => {
const res = await del(`/api/progress/${TEST_SLUG}`);
expect(res.status, 'status').toBeOneOf(200, 204);
});
} else {
console.log(' ⚠ No books — skipping progress tests');
}
// ── 8. Library ────────────────────────────────────────────────────────────────
console.log('\n8. Library');
await assert('GET /api/library returns object', async () => {
const res = await get('/api/library', { followRedirects: true });
expect(res.status, 'status').toBe(200);
const data = await res.json();
expect(typeof data, 'data type').toBe('object');
});
if (TEST_SLUG) {
await assert(`POST /api/library/${TEST_SLUG} saves book`, async () => {
const res = await post(`/api/library/${TEST_SLUG}`, {});
expect(res.status, 'status').toBeOneOf(200, 201);
});
await assert(`DELETE /api/library/${TEST_SLUG} removes book`, async () => {
const res = await del(`/api/library/${TEST_SLUG}`);
expect(res.status, 'status').toBeOneOf(200, 204);
});
}
// ── 9. Settings ───────────────────────────────────────────────────────────────
console.log('\n9. Settings');
await assert('GET /api/settings returns settings object', async () => {
const res = await get('/api/settings', { followRedirects: true });
expect(res.status, 'status').toBe(200);
const data = await res.json();
expect(typeof data, 'data type').toBe('object');
});
// ── 10. Sessions ──────────────────────────────────────────────────────────────
console.log('\n10. Sessions');
await assert('GET /api/sessions (authenticated) returns sessions array', async () => {
const res = await get('/api/sessions', { followRedirects: true, headers: { Cookie: authCookie } });
expect(res.status, 'status').toBe(200);
const data = await res.json();
// Returns { sessions: [...] }
expect(data, 'data').toContainKey('sessions');
expect(data.sessions, 'sessions').toBeArray();
});
// ── 11. Comments ──────────────────────────────────────────────────────────────
console.log('\n11. Comments');
if (TEST_SLUG) {
await assert(`GET /api/comments/${TEST_SLUG} returns comments`, async () => {
const res = await get(`/api/comments/${TEST_SLUG}`, { followRedirects: true });
expect(res.status, 'status').toBe(200);
const data = await res.json();
// Returns { comments: [...], myVotes: {}, avatarUrls: {} }
expect(data, 'data').toContainKey('comments');
expect(data.comments, 'comments').toBeArray();
});
}
// ── 12. Chapter endpoints ─────────────────────────────────────────────────────
console.log('\n12. Chapter endpoints');
if (TEST_SLUG) {
await assert(`GET /api/chapter/${TEST_SLUG}/1 returns 200 or 404`, async () => {
const res = await get(`/api/chapter/${TEST_SLUG}/1`, { followRedirects: true });
// 200 if chapter exists in MinIO, 404 if not
expect(res.status, 'status').toBeOneOf(200, 404);
});
await assert(`GET /api/chapter-text-preview/${TEST_SLUG}/1 returns 200 or error`, async () => {
const res = await get(`/api/chapter-text-preview/${TEST_SLUG}/1`, { followRedirects: true });
expect(res.status, 'status').toBeOneOf(200, 404, 500, 503);
});
}
// ── Summary ───────────────────────────────────────────────────────────────────
console.log('\n─────────────────────────────────────');
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failures.length > 0) {
console.log('\nFailures:');
for (const f of failures) {
console.log(`${f.name}`);
console.log(` ${f.reason}`);
}
}
console.log('─────────────────────────────────────\n');
process.exit(failed > 0 ? 1 : 0);

250
v3/scripts/pb-init-v3.sh Executable file
View File

@@ -0,0 +1,250 @@
#!/bin/sh
# pb-init-v3.sh — idempotent PocketBase bootstrap for the v3 stack.
#
# Safe to re-run: existing collections and fields are silently skipped.
#
# Env vars (defaults match docker-compose.yml):
# POCKETBASE_URL http://pocketbase:8090
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
# POCKETBASE_ADMIN_PASSWORD changeme123
set -e
PB="${POCKETBASE_URL:-http://pocketbase:8090}"
EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
PASS="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
log() { printf '[pb-init] %s\n' "$*"; }
# ── 0. Ensure dependencies ────────────────────────────────────────────────────
command -v curl > /dev/null 2>&1 || apk add --no-cache curl > /dev/null 2>&1
command -v python3 > /dev/null 2>&1 || apk add --no-cache python3 > /dev/null 2>&1
# ── 1. Wait for PocketBase ────────────────────────────────────────────────────
log "waiting for PocketBase..."
until curl -sf "$PB/api/health" > /dev/null 2>&1; do sleep 2; done
log "PocketBase ready"
# ── 2. Bootstrap superuser (first-run only) ───────────────────────────────────
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB/_/" 2>/dev/null || true)
if echo "$LOCATION" | grep -q "pbinstal/"; then
TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
curl -sf -X POST "$PB/api/collections/_superusers/records" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"passwordConfirm\":\"$PASS\"}" \
> /dev/null 2>&1 || true
log "superuser created"
fi
# ── 3. Authenticate ───────────────────────────────────────────────────────────
AUTH=$(curl -sf -X POST "$PB/api/collections/_superusers/auth-with-password" \
-H "Content-Type: application/json" \
-d "{\"identity\":\"$EMAIL\",\"password\":\"$PASS\"}")
TOK=$(echo "$AUTH" | sed 's/.*"token":"\([^"]*\)".*/\1/')
[ -z "$TOK" ] || [ "$TOK" = "$AUTH" ] && { log "ERROR: auth failed"; exit 1; }
log "authenticated"
# ── Helpers ───────────────────────────────────────────────────────────────────
# create NAME BODY — POST collection; 400/422 = already exists, treated as ok.
create() {
NAME="$1"; BODY="$2"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$PB/api/collections" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOK" \
-d "$BODY")
case "$STATUS" in
200|201) log "created: $NAME" ;;
400|422) log "exists (skip): $NAME" ;;
*) log "WARNING: $NAME returned $STATUS" ;;
esac
}
# add_field COLLECTION FIELD_NAME FIELD_TYPE
# Fetches current schema, appends field if absent, PATCHes collection.
# Requires python3 for safe JSON manipulation.
add_field() {
COLL="$1"; FIELD="$2"; TYPE="$3"
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
# Check existence and extract collection id + fields via python3
PARSED=$(echo "$SCHEMA" | python3 -c "
import sys, json
d = json.load(sys.stdin)
fields = d.get('fields', [])
exists = any(f.get('name') == '$FIELD' for f in fields)
print('exists=' + str(exists))
print('id=' + d.get('id', ''))
if not exists:
fields.append({'name': '$FIELD', 'type': '$TYPE'})
print('fields=' + json.dumps(fields))
" 2>/dev/null)
if echo "$PARSED" | grep -q "^exists=True"; then
log "field exists (skip): $COLL.$FIELD"; return
fi
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X PATCH "$PB/api/collections/$COLL_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOK" \
-d "{\"fields\":${NEW_FIELDS}}")
case "$STATUS" in
200|201) log "added field: $COLL.$FIELD ($TYPE)" ;;
*) log "WARNING: add_field $COLL.$FIELD returned $STATUS" ;;
esac
}
# ── 4. Collections ────────────────────────────────────────────────────────────
create "books" '{
"name":"books","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"title", "type":"text", "required":true},
{"name":"author", "type":"text"},
{"name":"cover", "type":"text"},
{"name":"status", "type":"text"},
{"name":"genres", "type":"json"},
{"name":"summary", "type":"text"},
{"name":"total_chapters","type":"number"},
{"name":"source_url", "type":"text"},
{"name":"ranking", "type":"number"},
{"name":"meta_updated", "type":"text"}
]}'
create "chapters_idx" '{
"name":"chapters_idx","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"number","type":"number", "required":true},
{"name":"title", "type":"text"}
]}'
create "ranking" '{
"name":"ranking","type":"base","fields":[
{"name":"rank", "type":"number","required":true},
{"name":"slug", "type":"text", "required":true},
{"name":"title", "type":"text"},
{"name":"author", "type":"text"},
{"name":"cover", "type":"text"},
{"name":"status", "type":"text"},
{"name":"genres", "type":"json"},
{"name":"source_url","type":"text"}
]}'
create "progress" '{
"name":"progress","type":"base","fields":[
{"name":"session_id","type":"text", "required":true},
{"name":"slug", "type":"text", "required":true},
{"name":"chapter", "type":"number"},
{"name":"user_id", "type":"text"},
{"name":"audio_time","type":"number"},
{"name":"updated", "type":"text"}
]}'
create "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"},
{"name":"heartbeat_at", "type":"date"}
]}'
create "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"},
{"name":"heartbeat_at", "type":"date"}
]}'
create "app_users" '{
"name":"app_users","type":"base","fields":[
{"name":"username", "type":"text","required":true},
{"name":"password_hash","type":"text"},
{"name":"role", "type":"text"},
{"name":"avatar_url", "type":"text"},
{"name":"created", "type":"text"}
]}'
create "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":"text"},
{"name":"last_seen", "type":"text"}
]}'
create "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":"text"}
]}'
create "user_settings" '{
"name":"user_settings","type":"base","fields":[
{"name":"session_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"auto_next","type":"bool"},
{"name":"voice", "type":"text"},
{"name":"speed", "type":"number"},
{"name":"updated", "type":"text"}
]}'
create "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":"text"}
]}'
create "book_comments" '{
"name":"book_comments","type":"base","fields":[
{"name":"slug", "type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"username", "type":"text"},
{"name":"body", "type":"text"},
{"name":"upvotes", "type":"number"},
{"name":"downvotes","type":"number"},
{"name":"parent_id","type":"text"},
{"name":"created", "type":"text"}
]}'
create "comment_votes" '{
"name":"comment_votes","type":"base","fields":[
{"name":"comment_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"session_id","type":"text"},
{"name":"vote", "type":"text"}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date"
add_field "progress" "user_id" "text"
add_field "progress" "audio_time" "number"
add_field "progress" "updated" "text"
add_field "books" "meta_updated" "text"
log "done"

5
v3/ui/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
build
.svelte-kit
.env
.env.*

20
v3/ui/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# libnovel UI — environment variables
# Copy to .env and adjust; do NOT commit with real secrets.
# Internal URL of the backend API (used by SvelteKit server-side load functions)
# In docker-compose this is the internal service name
BACKEND_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

23
v3/ui/.gitignore vendored Normal file
View File

@@ -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-*

1
v3/ui/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

71
v3/ui/AGENTS.md Normal file
View File

@@ -0,0 +1,71 @@
# LibNovel UI — Agent Context
SvelteKit 2 + Svelte 5 frontend. Node adapter for production; served behind Caddy.
## Design System
**ACTIVE_STYLE: branded**
Custom amber + zinc dark palette. No shadcn CLI defaults — primitives are hand-authored to match.
| Token | Value |
|-------|-------|
| Accent | `#f59e0b` (amber-400) |
| Surface-1 | zinc-900 |
| Surface-2 | zinc-800 |
| Surface-3 | zinc-700 |
| Text-primary | zinc-100 |
| Text-secondary | zinc-400 |
| Destructive | red-400 |
## Tailwind
**Version: 4** — configured entirely via `@theme {}` in `src/app.css` and the `@tailwindcss/vite` plugin.
**There is no `tailwind.config.ts`** — do not create one.
**Do not run `npx shadcn-svelte add ...`** — primitives are hand-authored in `$lib/components/ui/`.
## shadcn-svelte Primitives
All in `src/lib/components/ui/`:
| Component | Variants / Notes |
|-----------|-----------------|
| `button` | default / secondary / outline / ghost / destructive / link; sizes: default / sm / lg / icon |
| `badge` | default / secondary / outline / destructive |
| `card` | Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter |
| `textarea` | bindable `value` prop |
| `dialog` | Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter |
| `separator` | horizontal / vertical |
Always use `cn()` from `$lib/utils` — never template-literal class conditionals.
## Svelte 5 Conventions
- `@Observable` / runes (`$state`, `$derived`, `$effect`, `$props()`) for all new code.
- Do not add `ObservableObject` / `@Published` — they don't exist in Svelte; don't introduce legacy `writable` stores for new code.
- Navigation: `goto()` from `$app/navigation`; `page` from `$app/state`.
## iOS/UX Skill
For any view work, load the `ios-ux` skill at task start:
```
skill({ name: "ios-ux" })
```
## Key Files
| File | Role |
|------|------|
| `src/app.css` | Tailwind v4 `@theme` tokens — source of truth for all design tokens |
| `src/lib/utils.ts` | `cn()` helper (clsx + tailwind-merge) |
| `src/lib/types.ts` | Shared client-safe domain types |
| `src/routes/+layout.svelte` | Root layout: sticky nav, persistent `<audio>` element, mini-player bar |
| `src/lib/audio.svelte.ts` | Global audio store (Svelte 5 runes class) |
| `src/lib/components/ui/` | shadcn-style UI primitives |
## Persistent Audio Element
The `<audio>` element in `+layout.svelte` **must never be conditionally rendered** (no `{#if}`).
Conditional rendering destroys and recreates the element, triggering `onpause` mid-playback.
Keep it always in the DOM; control it via `audioStore`.

44
v3/ui/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# 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 most server-side code, but packages with dynamic
# requires or native bindings (e.g. ioredis) are not inlined by Rollup and
# must be present in node_modules at runtime. We install only production
# deps (no devDependencies) to keep the image small.
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
EXPOSE $PORT
CMD ["node", "build"]

42
v3/ui/README.md Normal file
View File

@@ -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.

17
v3/ui/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "",
"css": "src/app.css",
"baseColor": "zinc",
"cssVariables": false
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true
}

4284
v3/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
v3/ui/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"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",
"clsx": "^2.1.1",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwind-merge": "^3.5.0",
"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",
"ioredis": "^5.3.2",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
}
}

65
v3/ui/src/app.css Normal file
View File

@@ -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;
}

18
v3/ui/src/app.d.ts vendored Normal file
View File

@@ -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 {};

17
v3/ui/src/app.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192" />
<link rel="icon" type="image/png" href="/icon-512.png" sizes="512x512" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

155
v3/ui/src/hooks.server.ts Normal file
View File

@@ -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 "<payload>.<signature>".
*/
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: "<userId>:<username>:<role>:<authSessionId>"
* 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);
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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> element once and
* never unmounts it; the per-chapter AudioPlayer component is just a
* controller that reads/writes this state.
*
* Uses Svelte 5 runes ($state / $derived) — import only from .svelte files
* or other .svelte.ts files.
*
* ── State machine ────────────────────────────────────────────────────────────
*
* Current chapter (status):
* idle → loading → ready (fast path: audio exists in MinIO)
* idle → loading → generating → ready (slow path: Kokoro TTS)
* any → error
*
* Next chapter pre-fetch (nextStatus):
* 'none' no next chapter, or auto-next is off
* 'prefetching' POST /api/audio running for the next chapter
* 'prefetched' next chapter audio is ready in MinIO
* 'failed' pre-generation failed (will retry on navigate)
*
* Auto-next transition:
* onended fires → navigate to next chapter URL
* ↳ new chapter page mounts
* • if nextStatus === 'prefetched' → presign + play immediately
* • else → normal startPlayback() flow
*
* Pre-fetch is triggered when currentTime / duration >= 0.9 (90% mark).
* It only runs once per chapter (guarded by nextStatus !== 'none').
*/
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
class AudioStore {
// ── What is loaded ──────────────────────────────────────────────────────
slug = $state('');
chapter = $state(0);
chapterTitle = $state('');
bookTitle = $state('');
voice = $state('af_bella');
speed = $state(1.0);
/** Cover image URL for the currently loaded book. */
cover = $state('');
/** Full chapter list for the currently loaded book (number + title). */
chapters = $state<{ number: number; title: string }[]>([]);
// ── Loading/generation state ────────────────────────────────────────────
status = $state<AudioStatus>('idle');
audioUrl = $state('');
errorMsg = $state('');
/** Pseudo-progress bar value 0100 during generation */
progress = $state(0);
// ── Playback state (kept in sync with the <audio> element) ─────────────
currentTime = $state(0);
duration = $state(0);
isPlaying = $state(false);
/**
* Increment to signal the layout to toggle play/pause.
* The layout watches this with $effect and calls audioEl.play()/pause().
*/
toggleRequest = $state(0);
/**
* Set to a number to seek the audio element to that time (seconds).
* The layout watches this with $effect and sets audioEl.currentTime.
* Reset to null after handling.
*/
seekRequest = $state<number | null>(null);
// ── Auto-next ────────────────────────────────────────────────────────────
/**
* When true, navigates to the next chapter when the current one ends
* and auto-starts its audio.
*/
autoNext = $state(false);
/**
* The next chapter number for the currently playing chapter, or null if
* there is no next chapter. Written by the chapter page's AudioPlayer.
* Stored here (not cleared on unmount) so onended can still read it after
* the component unmounts due to {#key} re-render on navigation.
*/
nextChapter = $state<number | null>(null);
/**
* Set to the chapter number that should auto-start by the layout's onended
* handler (when autoNext fires a navigation). The AudioPlayer on the new
* page checks this on mount: if it matches the component's own chapter prop
* it starts playback and clears the value.
*
* Using the target chapter number (instead of a plain boolean) prevents the
* still-mounted outgoing AudioPlayer from reacting to the flag before the
* navigation completes — it only matches the incoming chapter's component.
*/
autoStartChapter = $state<number | null>(null);
// ── Next-chapter pre-fetch state ─────────────────────────────────────────
/**
* State of the background pre-generation for the next chapter.
* 'none' nothing started (default / no next chapter)
* 'prefetching' currently running POST /api/audio for next chapter
* 'prefetched' next chapter audio confirmed ready in MinIO
* 'failed' pre-generation failed (fallback: generate on navigate)
*/
nextStatus = $state<NextStatus>('none');
/**
* The presigned URL obtained during pre-fetch. When the user navigates
* to the next chapter, AudioPlayer picks this up and skips straight to play.
*/
nextAudioUrl = $state('');
/** Progress value (0100) shown while pre-generating the next chapter. */
nextProgress = $state(0);
/** Which chapter number the pre-fetch state above belongs to. */
nextChapterPrefetched = $state<number | null>(null);
/** Whether the mini-bar at the bottom is visible */
get active(): boolean {
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
}
/** True when the currently loaded track matches slug+chapter */
isCurrentChapter(slug: string, chapter: number): boolean {
return this.slug === slug && this.chapter === chapter;
}
/** Reset all next-chapter pre-fetch state. */
resetNextPrefetch() {
this.nextStatus = 'none';
this.nextAudioUrl = '';
this.nextProgress = 0;
this.nextChapterPrefetched = null;
}
}
export const audioStore = new AudioStore();

View File

@@ -0,0 +1,851 @@
<script lang="ts">
/**
* AudioPlayer — controller component.
*
* Does NOT own an <audio> element. Instead it reads/writes `audioStore`,
* which is shared with the layout's persistent <audio> element so audio
* survives SvelteKit navigations.
*
* ── Play flow ────────────────────────────────────────────────────────────
* On "Play narration" click / auto-start:
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
* 2. If the pre-fetch already landed (nextStatus='prefetched' AND
* nextChapterPrefetched === chapter), use the cached URL immediately.
* 3. Otherwise try GET /api/presign/audio — if 200, set audioUrl → layout plays.
* 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
* On success (200 or 202→done), presign and set audioUrl directly from MinIO.
*
* ── Voice selection ──────────────────────────────────────────────────────
* A "Change voice" panel lets users pick from the available Kokoro voices.
* Each voice shows a play button that streams a pre-generated sample from
* MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated
* server-side via POST /api/audio/voice-samples.
*
* Changing voice updates audioStore.voice (saved to settings via layout).
* The currently loaded chapter audio is NOT re-generated automatically —
* the new voice takes effect on next "Play narration" click.
*
* ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
* When autoNext is on, prefetchNext() is called as soon as the current
* chapter starts playing (via maybeStartPrefetch() at the end of
* startPlayback()). This gives the maximum lead time for Kokoro to
* generate the next chapter so the transition is seamless.
*
* A $effect also watches currentTime/duration and fires prefetchNext() at
* the 90% mark as a fallback — covering the case where autoNext was toggled
* on mid-playback after startPlayback() had already returned.
* The nextStatus !== 'none' guard prevents double-runs in all cases.
*
* prefetchNext():
* • Calls POST /api/audio for next chapter (sets nextStatus='prefetching')
* • On success, presigns and stores URL in audioStore.nextAudioUrl
* (sets nextStatus='prefetched')
* • On failure, sets nextStatus='failed'
*
* ── Auto-next ────────────────────────────────────────────────────────────
* layout.svelte onended → sets autoStartPending=true → navigates.
* New chapter's AudioPlayer mounts → sees autoStartPending → startPlayback()
* which uses the prefetched URL if available.
*/
import { audioStore } from '$lib/audio.svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
interface Props {
slug: string;
chapter: number;
chapterTitle?: string;
bookTitle?: string;
/** Cover image URL for the book (used in MediaSession for lock-screen art). */
cover?: string;
/** Next chapter number, or null/undefined if this is the last chapter. */
nextChapter?: number | null;
/** Full chapter list for the book (number + title). Written into the store. */
chapters?: { number: number; title: string }[];
/** List of available voices from the Kokoro API. */
voices?: string[];
}
let {
slug,
chapter,
chapterTitle = '',
bookTitle = '',
cover = '',
nextChapter = null,
chapters = [],
voices = []
}: Props = $props();
// ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false);
/** Voice whose sample is currently being fetched or playing. */
let samplePlayingVoice = $state<string | null>(null);
/** Currently active sample <audio> element — one at a time. */
let sampleAudio = $state<HTMLAudioElement | null>(null);
/**
* Human-readable label for a voice ID.
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)"
*/
function voiceLabel(v: string): string {
const langMap: Record<string, string> = {
af: 'US', am: 'US',
bf: 'UK', bm: 'UK',
ef: 'ES', em: 'ES',
ff: 'FR',
hf: 'IN', hm: 'IN',
'if': 'IT', im: 'IT',
jf: 'JP', jm: 'JP',
pf: 'PT', pm: 'PT',
zf: 'ZH', zm: 'ZH',
};
const genderMap: Record<string, string> = {
af: 'F', am: 'M',
bf: 'F', bm: 'M',
ef: 'F', em: 'M',
ff: 'F',
hf: 'F', hm: 'M',
'if': 'F', im: 'M',
jf: 'F', jm: 'M',
pf: 'F', pm: 'M',
zf: 'F', zm: 'M',
};
const prefix = v.slice(0, 2);
const name = v.slice(3);
// Capitalise and strip legacy v0 prefix.
const displayName = name
.replace(/^v0/, '')
.replace(/^([a-z])/, (c: string) => c.toUpperCase());
const lang = langMap[prefix] ?? prefix.toUpperCase();
const gender = genderMap[prefix] ?? '?';
return `${displayName} (${lang} ${gender})`;
}
/** Stop any currently playing sample. */
function stopSample() {
if (sampleAudio) {
sampleAudio.pause();
sampleAudio.src = '';
sampleAudio = null;
}
samplePlayingVoice = null;
}
/** Play a voice sample from MinIO. */
async function playSample(voice: string) {
// If this voice is already playing, stop it.
if (samplePlayingVoice === voice) {
stopSample();
return;
}
stopSample();
samplePlayingVoice = voice;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voice)}`);
if (res.status === 404) {
// Sample not generated yet — silently ignore
samplePlayingVoice = null;
return;
}
if (!res.ok) throw new Error(`presign failed: ${res.status}`);
const data = (await res.json()) as { url: string };
const audio = new Audio(data.url);
sampleAudio = audio;
audio.onended = () => {
if (samplePlayingVoice === voice) stopSample();
};
audio.onerror = () => {
if (samplePlayingVoice === voice) stopSample();
};
await audio.play();
} catch {
samplePlayingVoice = null;
}
}
/** Select a voice and close the panel. */
function selectVoice(voice: string) {
stopSample();
audioStore.voice = voice;
showVoicePanel = false;
}
// Keep nextChapter in the store so the layout's onended can navigate.
// NOTE: we do NOT clear on unmount here — the store retains the value so
// onended (which may fire after {#key} unmounts this component) can still
// read it. The value is superseded when the new chapter mounts.
$effect(() => {
audioStore.nextChapter = nextChapter ?? null;
});
// Auto-start: if the layout navigated here via auto-next, kick off playback.
// We match against the chapter prop so the outgoing chapter's AudioPlayer
// (still mounted during the brief navigation window) never reacts to this.
$effect(() => {
if (audioStore.autoStartChapter === chapter) {
audioStore.autoStartChapter = null;
startPlayback();
}
});
// Reset next-chapter prefetch state when this chapter changes (new page).
// Only reset if the prefetch belongs to neither the current chapter
// (about to be consumed by startPlayback) nor the next chapter (still valid).
// Any other value means stale data from a previous page.
$effect(() => {
const prefetchedFor = audioStore.nextChapterPrefetched;
if (
prefetchedFor !== null &&
prefetchedFor !== chapter &&
prefetchedFor !== (nextChapter ?? null)
) {
audioStore.resetNextPrefetch();
}
});
// Close voice panel when user clicks outside (escape key).
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
stopSample();
showVoicePanel = false;
}
}
// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
// Watch playback progress; when >= 90% of current chapter, pre-generate
// the next chapter's audio so it's ready when we navigate.
$effect(() => {
const ct = audioStore.currentTime;
const dur = audioStore.duration;
const isCurrentlyPlaying = audioStore.isCurrentChapter(slug, chapter);
if (
!isCurrentlyPlaying ||
!audioStore.autoNext ||
nextChapter === null ||
nextChapter === undefined ||
dur <= 0 ||
ct / dur < 0.9 ||
audioStore.nextStatus !== 'none'
) {
return;
}
// Trigger exactly once (nextStatus transitions away from 'none')
prefetchNext();
});
// ── Pseudo progress helpers ────────────────────────────────────────────────
let progressRafId = 0;
function startProgress() {
audioStore.progress = 0;
let last = performance.now();
function tick(now: number) {
const dt = (now - last) / 1000;
last = now;
let rate: number;
if (audioStore.progress < 30) rate = 4;
else if (audioStore.progress < 60) rate = 12;
else if (audioStore.progress < 80) rate = 4;
else rate = 0.3;
audioStore.progress = Math.min(audioStore.progress + rate * dt, 99);
if (audioStore.progress < 99) {
progressRafId = requestAnimationFrame(tick);
}
}
progressRafId = requestAnimationFrame(tick);
}
function stopProgress() {
if (progressRafId) {
cancelAnimationFrame(progressRafId);
progressRafId = 0;
}
}
async function finishProgress() {
stopProgress();
const step = () => {
audioStore.progress = Math.min(audioStore.progress + 8, 100);
if (audioStore.progress < 100) {
progressRafId = requestAnimationFrame(step);
}
};
progressRafId = requestAnimationFrame(step);
await new Promise((r) => setTimeout(r, 200));
stopProgress();
}
// ── Next-chapter pseudo-progress helpers ──────────────────────────────────
let nextProgressRafId = 0;
function startNextProgress() {
audioStore.nextProgress = 0;
let last = performance.now();
function tick(now: number) {
const dt = (now - last) / 1000;
last = now;
let rate: number;
if (audioStore.nextProgress < 30) rate = 4;
else if (audioStore.nextProgress < 60) rate = 12;
else if (audioStore.nextProgress < 80) rate = 4;
else rate = 0.3;
audioStore.nextProgress = Math.min(audioStore.nextProgress + rate * dt, 99);
if (audioStore.nextProgress < 99) {
nextProgressRafId = requestAnimationFrame(tick);
}
}
nextProgressRafId = requestAnimationFrame(tick);
}
function stopNextProgress() {
if (nextProgressRafId) {
cancelAnimationFrame(nextProgressRafId);
nextProgressRafId = 0;
}
}
// ── API helpers ────────────────────────────────────────────────────────────
async function tryPresign(
targetSlug: string,
targetChapter: number,
targetVoice: string
): Promise<string | null> {
const params = new URLSearchParams({
slug: targetSlug,
n: String(targetChapter),
voice: targetVoice
});
const res = await fetch(`/api/presign/audio?${params}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
const data = (await res.json()) as { url: string };
return data.url;
}
type AudioStatusResponse =
| { status: 'done' }
| { status: 'pending' | 'generating'; job_id: string }
| { status: 'idle' }
| { status: 'failed'; error?: string };
/**
* Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
* until status is "done" or "failed" (or the caller cancels via signal).
*
* Returns the final status response, or throws on network error / cancellation.
*/
async function pollAudioStatus(
targetSlug: string,
targetChapter: number,
targetVoice: string,
intervalMs = 2000,
signal?: AbortSignal
): Promise<AudioStatusResponse> {
const qs = new URLSearchParams();
if (targetVoice) qs.set('voice', targetVoice);
const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;
while (true) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
const data = (await res.json()) as AudioStatusResponse;
if (data.status === 'done' || data.status === 'failed') {
return data;
}
// Still pending/generating — wait then retry.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, intervalMs);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
}
// ── Pre-fetch next chapter ─────────────────────────────────────────────────
async function prefetchNext() {
if (nextChapter === null || nextChapter === undefined) return;
if (audioStore.nextStatus !== 'none') return; // already running or done
const voice = audioStore.voice;
audioStore.nextStatus = 'prefetching';
audioStore.nextChapterPrefetched = nextChapter;
startNextProgress();
try {
// Fast path: already generated
const url = await tryPresign(slug, nextChapter, voice);
if (url) {
stopNextProgress();
audioStore.nextProgress = 100;
audioStore.nextAudioUrl = url;
audioStore.nextStatus = 'prefetched';
return;
}
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// Whether the server returned 200 (already cached) or 202 (enqueued),
// always presign — the status endpoint no longer returns a proxy URL.
if (res.status === 200) {
// Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
await res.body?.cancel();
}
// else 202: generation enqueued — fall through to poll.
if (res.status !== 200) {
// 202: poll until done.
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
}
} else {
stopNextProgress();
audioStore.nextProgress = 100;
}
// Audio is ready in MinIO — get a direct presigned URL.
const doneUrl = await tryPresign(slug, nextChapter, voice);
if (!doneUrl) throw new Error('Prefetch: audio done but presign returned 404');
audioStore.nextAudioUrl = doneUrl;
audioStore.nextStatus = 'prefetched';
} catch {
stopNextProgress();
audioStore.nextStatus = 'failed';
}
}
// ── Media Session ──────────────────────────────────────────────────────────
// Sets the OS-level media metadata so the book cover, title, and chapter
// appear on the phone lock screen / notification center.
function setMediaSession() {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
const artwork: MediaImage[] = cover
? [
{ src: cover, sizes: '512x512', type: 'image/jpeg' },
{ src: cover, sizes: '256x256', type: 'image/jpeg' }
]
: [];
navigator.mediaSession.metadata = new MediaMetadata({
title: chapterTitle || `Chapter ${chapter}`,
artist: bookTitle,
album: bookTitle,
artwork
});
}
// ── Core play flow ─────────────────────────────────────────────────────────
async function startPlayback() {
const voice = audioStore.voice;
// Populate store metadata so layout + mini-bar have track info.
audioStore.slug = slug;
audioStore.chapter = chapter;
audioStore.chapterTitle = chapterTitle;
audioStore.bookTitle = bookTitle;
audioStore.cover = cover;
audioStore.chapters = chapters;
// Update OS media session (lock screen / notification center).
setMediaSession();
audioStore.status = 'loading';
audioStore.errorMsg = '';
try {
// Fast path A: pre-fetch already landed for THIS chapter.
if (
audioStore.nextStatus === 'prefetched' &&
audioStore.nextChapterPrefetched === chapter &&
audioStore.nextAudioUrl
) {
const url = audioStore.nextAudioUrl;
// Consume the pre-fetch — reset so it doesn't carry over
audioStore.resetNextPrefetch();
audioStore.audioUrl = url;
audioStore.status = 'ready';
// Don't restore saved time for auto-next; position is 0
// Immediately start pre-generating the chapter after this one.
maybeStartPrefetch();
return;
}
// Fast path B: audio already in MinIO (presign check).
const url = await tryPresign(slug, chapter, voice);
if (url) {
audioStore.audioUrl = url;
audioStore.status = 'ready';
// Restore last saved position after the audio element loads
restoreSavedAudioTime();
// Immediately start pre-generating the next chapter in background.
maybeStartPrefetch();
return;
}
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
audioStore.status = 'generating';
startProgress();
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status !== 200) {
// 202: generation enqueued — poll until done.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
} else {
// 200: already cached — body is { status: 'done' }, no url needed.
await res.body?.cancel();
}
await finishProgress();
// Audio is ready in MinIO — always use a presigned URL for direct playback.
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl;
audioStore.status = 'ready';
// Don't restore time for freshly generated audio — position is 0
// Immediately start pre-generating the next chapter in background.
maybeStartPrefetch();
} catch (e) {
stopProgress();
audioStore.progress = 0;
audioStore.status = 'error';
audioStore.errorMsg = String(e);
}
}
/**
* Start pre-fetching the next chapter if autoNext is on, there is a next
* chapter, and no prefetch is already running or completed.
* Called as soon as current-chapter playback begins so that the next
* chapter's audio is ready before we need it (seamless transition).
* The 90%-mark $effect acts as a fallback for cases where autoNext is
* toggled on mid-playback.
*/
function maybeStartPrefetch() {
if (
audioStore.autoNext &&
nextChapter !== null &&
nextChapter !== undefined &&
audioStore.nextStatus === 'none'
) {
prefetchNext();
}
}
/**
* Fetch the saved audio time for this chapter and seek to it after a short
* delay (to allow the audio element to load the source).
*/
async function restoreSavedAudioTime() {
try {
const params = new URLSearchParams({ slug, chapter: String(chapter) });
const res = await fetch(`/api/progress/audio-time?${params}`);
if (!res.ok) return;
const data = (await res.json()) as { audioTime: number | null };
if (data.audioTime && data.audioTime > 5) {
// Small delay to let the <audio> element fully load the src before seeking
setTimeout(() => {
audioStore.seekRequest = data.audioTime as number;
}, 300);
}
} catch {
// Non-critical — silently ignore
}
}
async function handlePlay() {
const isCurrent = audioStore.isCurrentChapter(slug, chapter);
// Already loaded this chapter: toggle play/pause.
if (isCurrent && audioStore.status === 'ready') {
audioStore.toggleRequest = (audioStore.toggleRequest ?? 0) + 1;
return;
}
// Not yet loaded — start the full flow.
await startPlayback();
}
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
</div>
<!-- Voice selector button -->
{#if voices.length > 0}
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
title="Change voice"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
<svg class={cn('w-3 h-3 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z"/>
</svg>
</Button>
{/if}
</div>
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
{#if showVoicePanel && voices.length > 0}
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
onclick={() => { stopSample(); showVoicePanel = false; }}
aria-label="Close voice selector"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</Button>
</div>
<div class="max-h-64 overflow-y-auto">
{#each voices as v (v)}
<div
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v && 'bg-amber-400/10')}
role="button"
tabindex="0"
onclick={() => selectVoice(v)}
onkeydown={(e) => e.key === 'Enter' && selectVoice(v)}
>
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v}
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</div>
<!-- Voice name -->
<span class={cn('flex-1 text-xs', audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
{voiceLabel(v)}
</span>
<span class="text-zinc-600 text-xs font-mono">{v}</span>
<!-- Sample play button (stop propagation so click doesn't select) -->
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
onclick={(e) => { e.stopPropagation(); playSample(v); }}
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
>
{#if samplePlayingVoice === v}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z"/>
</svg>
{:else}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</Button>
</div>
{/each}
</div>
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
<p class="text-xs text-zinc-500">
New voice applies on next "Play narration".
{#if voices.length > 0}
<a
href="/api/audio/voice-samples"
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
onclick={(e) => {
e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
}}
>Generate missing samples</a>
{/if}
</p>
</div>
</div>
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── This chapter is the active one ── -->
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
{#if audioStore.status === 'error'}
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</Button>
{:else if audioStore.status === 'loading'}
<Button variant="default" size="sm" disabled>
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading…
</Button>
{:else if audioStore.status === 'generating'}
<div class="space-y-2">
<p class="text-xs text-zinc-400">Generating narration…</p>
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
class="h-full bg-amber-400 rounded-full transition-none"
style="width: {audioStore.progress}%"
></div>
</div>
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p>
</div>
{:else if audioStore.status === 'ready'}
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-zinc-400">
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>Playing — controls below</span>
{:else}
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Paused — controls below</span>
{/if}
<span class="tabular-nums text-zinc-500">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
<!-- Auto-next toggle (keep here as useful context) -->
{#if nextChapter !== null && nextChapter !== undefined}
<Button
variant="ghost"
size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
aria-pressed={audioStore.autoNext}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
</Button>
{/if}
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-zinc-500">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-zinc-500 flex items-center gap-1">
<svg class="w-3 h-3 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
{/if}
</div>
{/if}
{/if}
{:else if audioStore.active}
<!-- ── A different chapter is currently playing ── -->
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-zinc-400">
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
</p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
Load this chapter
</Button>
</div>
{:else}
<!-- ── Idle — nothing playing ── -->
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</Button>
{/if}
</div>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Cropper from 'cropperjs';
import type { default as CropperType } from 'cropperjs';
import 'cropperjs/dist/cropper.css';
import { Dialog, DialogHeader, DialogTitle, DialogFooter } from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
interface Props {
file: File;
onconfirm: (blob: Blob, mimeType: string) => void;
oncancel: () => void;
}
let { file, onconfirm, oncancel }: Props = $props();
let imgEl: HTMLImageElement | undefined = $state();
let cropper: CropperType | null = null;
let objectUrl = '';
let open = $state(true);
// Initialize cropper once the img element is bound and the file is known.
// Use a $effect so it runs after the DOM is ready (replaces onMount).
$effect(() => {
if (!imgEl || !file) return;
// Create the object URL and set src directly on the element (not via reactive
// state) so cropperjs sees the correct src before the image load event fires.
objectUrl = URL.createObjectURL(file);
imgEl.src = objectUrl;
// Cropperjs must be initialised inside the image's load event so it can
// measure the natural dimensions — if we call new Cropper() before the image
// has loaded, the crop canvas is blank/invisible.
const handleLoad = () => {
cropper = new Cropper(imgEl!, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
restore: false,
guides: false,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
background: false
});
};
imgEl.addEventListener('load', handleLoad, { once: true });
return () => {
imgEl?.removeEventListener('load', handleLoad);
cropper?.destroy();
cropper = null;
URL.revokeObjectURL(objectUrl);
objectUrl = '';
};
});
onDestroy(() => {
cropper?.destroy();
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
function confirm() {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
canvas.toBlob(
(blob: Blob | null) => {
if (blob) onconfirm(blob, mimeType);
},
mimeType,
0.9
);
}
function handleClose() {
open = false;
oncancel();
}
</script>
<Dialog bind:open onclose={handleClose} class="max-w-sm">
<DialogHeader>
<DialogTitle>Crop profile picture</DialogTitle>
</DialogHeader>
<!-- Cropper image container — overflow must be visible so cropperjs can
render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. -->
<div class="px-5">
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
<img
bind:this={imgEl}
alt="Crop preview"
style="display:block; max-width:100%; max-height:100%;"
/>
</div>
<p class="text-xs text-zinc-500 text-center mt-3">
Drag to reposition · pinch or scroll to zoom · drag corners to resize
</p>
</div>
<DialogFooter>
<Button variant="outline" onclick={handleClose}>Cancel</Button>
<Button variant="default" onclick={confirm}>Use photo</Button>
</DialogFooter>
</Dialog>

View File

@@ -0,0 +1,561 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { cn } from '$lib/utils';
interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
parent_id?: string;
replies?: BookComment[];
}
let {
slug,
isLoggedIn = false,
currentUserId = ''
}: {
slug: string;
isLoggedIn?: boolean;
currentUserId?: string;
} = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<BookComment[]>([]);
let myVotes = $state<Record<string, 'up' | 'down'>>({});
let avatarUrls = $state<Record<string, string>>({});
let loading = $state(true);
let loadError = $state('');
// Top-level new comment
let newBody = $state('');
let posting = $state(false);
let postError = $state('');
// Sort
let sort = $state<'new' | 'top'>('top');
// Reply state: which comment is being replied to
let replyingTo = $state<string | null>(null); // comment id
let replyBody = $state('');
let replyPosting = $state(false);
let replyError = $state('');
// Delete in-flight set
let deletingIds = $state(new Set<string>());
// Per-comment vote inflight set (prevents double-clicks)
let votingIds = $state(new Set<string>());
// ── Load comments ─────────────────────────────────────────────────────────
async function loadComments() {
loading = true;
loadError = '';
try {
const res = await fetch(
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
comments = data.comments ?? [];
myVotes = data.myVotes ?? {};
avatarUrls = data.avatarUrls ?? {};
} catch (e) {
loadError = 'Failed to load comments.';
} finally {
loading = false;
}
}
$effect(() => {
loadComments();
});
// Re-load when sort changes (after initial mount)
let firstLoad = true;
$effect(() => {
// Read sort to create a dependency
const _ = sort;
if (firstLoad) { firstLoad = false; return; }
loadComments();
});
// ── Post top-level comment ────────────────────────────────────────────────
async function postComment() {
const text = newBody.trim();
if (!text || posting) return;
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
posting = true;
postError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
});
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
if (!res.ok) {
const err = await res.json().catch(() => ({}));
postError = err.message ?? 'Failed to post comment.';
return;
}
const created: BookComment = await res.json();
created.replies = [];
// Prepend for 'new', or re-sort for 'top'
if (sort === 'new') {
comments = [created, ...comments];
} else {
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
}
newBody = '';
} catch {
postError = 'Failed to post comment.';
} finally {
posting = false;
}
}
// ── Post reply ────────────────────────────────────────────────────────────
async function postReply(parentId: string) {
const text = replyBody.trim();
if (!text || replyPosting) return;
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
replyPosting = true;
replyError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text, parent_id: parentId })
});
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
if (!res.ok) {
const err = await res.json().catch(() => ({}));
replyError = err.message ?? 'Failed to post reply.';
return;
}
const created: BookComment = await res.json();
// Append to the parent's replies list
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return { ...c, replies: [...(c.replies ?? []), created] };
});
replyBody = '';
replyingTo = null;
} catch {
replyError = 'Failed to post reply.';
} finally {
replyPosting = false;
}
}
// ── Delete ────────────────────────────────────────────────────────────────
async function deleteComment(commentId: string, parentId?: string) {
if (deletingIds.has(commentId)) return;
deletingIds = new Set([...deletingIds, commentId]);
try {
const res = await fetch(`/api/comment/${commentId}`, { method: 'DELETE' });
if (!res.ok) return;
if (parentId) {
// Remove reply from parent
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
});
} else {
// Remove top-level comment
comments = comments.filter((c) => c.id !== commentId);
}
} finally {
const next = new Set(deletingIds);
next.delete(commentId);
deletingIds = next;
}
}
// ── Vote ──────────────────────────────────────────────────────────────────
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
if (votingIds.has(commentId)) return;
votingIds = new Set([...votingIds, commentId]);
try {
const res = await fetch(`/api/comment/${commentId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vote: v })
});
if (!res.ok) return;
const updated: BookComment = await res.json();
// Update comment in list (handle both top-level and replies)
if (parentId) {
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return {
...c,
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
};
});
} else {
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
}
// Update myVotes: toggle off if same, else set new vote
const prev = myVotes[commentId];
if (prev === v) {
const next = { ...myVotes };
delete next[commentId];
myVotes = next;
} else {
myVotes = { ...myVotes, [commentId]: v };
}
} finally {
const next = new Set(votingIds);
next.delete(commentId);
votingIds = next;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function initials(username: string): string {
const name = username.trim() || '?';
return name.slice(0, 2).toUpperCase();
}
function formatDate(iso: string): string {
try {
const date = new Date(iso);
const now = Date.now();
const diffMs = now - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
const charCount = $derived(newBody.length);
const charOver = $derived(charCount > 2000);
const replyCharCount = $derived(replyBody.length);
const replyCharOver = $derived(replyCharCount > 2000);
const totalCount = $derived(
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
);
</script>
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-zinc-200">
Comments
{#if !loading && totalCount > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
{/if}
</h2>
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => (sort = 'top')}
>Top</Button>
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => (sort = 'new')}
>New</Button>
</div>
{/if}
</div>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<Textarea
bind:value={newBody}
placeholder="Write a comment…"
rows={3}
/>
<div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}>
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-red-400">{postError}</span>
{/if}
<Button
variant="default"
size="sm"
disabled={posting || !newBody.trim() || charOver}
onclick={postComment}
>
{posting ? 'Posting…' : 'Post'}
</Button>
</div>
</div>
</div>
{:else}
<p class="text-sm text-zinc-500">
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
to leave a comment.
</p>
{/if}
</div>
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-red-400">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
{:else}
<div class="flex flex-col gap-3">
{#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}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
</div>
<!-- Body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title="Upvote"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</Button>
<!-- Downvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title="Downvote"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</Button>
<!-- Reply button -->
{#if isLoggedIn}
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
replyError = '';
} else {
replyingTo = comment.id;
replyBody = '';
replyError = '';
}
}}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
Reply
</Button>
{/if}
<!-- Delete (owner only) -->
{#if isOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete
</Button>
{/if}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
<Textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-red-400">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-zinc-400 hover:text-zinc-200"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>Cancel</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? 'Posting…' : 'Reply'}
</Button>
</div>
</div>
</div>
{/if}
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
{#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}
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
</div>
<!-- Reply body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title="Upvote"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title="Downvote"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</Button>
{#if replyIsOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
type Variant = 'default' | 'secondary' | 'outline' | 'destructive';
interface Props {
variant?: Variant;
class?: string;
children?: Snippet;
}
let { variant = 'default', class: className = '', children }: Props = $props();
const base =
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none';
const variants: Record<Variant, string> = {
default: 'border-transparent bg-amber-400 text-zinc-900',
secondary: 'border-transparent bg-zinc-700 text-zinc-200',
outline: 'border-zinc-600 text-zinc-300',
destructive: 'border-transparent bg-red-500/20 text-red-400',
};
</script>
<span class={cn(base, variants[variant], className)}>
{@render children?.()}
</span>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.svelte';

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
type Variant = 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link';
type Size = 'default' | 'sm' | 'lg' | 'icon';
interface Props {
variant?: Variant;
size?: Size;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
class?: string;
onclick?: (e: MouseEvent) => void;
children?: Snippet;
[key: string]: unknown;
}
let {
variant = 'default',
size = 'default',
disabled = false,
type = 'button',
class: className = '',
onclick,
children,
...rest
}: Props = $props();
const base =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:pointer-events-none disabled:opacity-50';
const variants: Record<Variant, string> = {
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300',
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300',
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto',
};
const sizes: Record<Size, string> = {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-11 rounded-md px-8',
icon: 'h-9 w-9',
};
</script>
<button
{type}
{disabled}
class={cn(base, variants[variant], sizes[size], className)}
{onclick}
{...rest}
>
{@render children?.()}
</button>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.svelte';

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('p-5 pt-0', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<p class={cn('text-sm text-zinc-400', className)}>
{@render children?.()}
</p>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex items-center p-5 pt-0', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex flex-col space-y-1.5 p-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}>
{@render children?.()}
</h3>

View File

@@ -0,0 +1,6 @@
export { default as Card } from './Card.svelte';
export { default as CardHeader } from './CardHeader.svelte';
export { default as CardTitle } from './CardTitle.svelte';
export { default as CardDescription } from './CardDescription.svelte';
export { default as CardContent } from './CardContent.svelte';
export { default as CardFooter } from './CardFooter.svelte';

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
open?: boolean;
onclose?: () => void;
class?: string;
children?: Snippet;
}
let { open = $bindable(false), onclose, class: className = '', children }: Props = $props();
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
open = false;
onclose?.();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
onclose?.();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
onclick={handleBackdropClick}
>
<div class={cn('bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm', className)}>
{@render children?.()}
</div>
</div>
{/if}

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex flex-col gap-4 p-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex items-center justify-end gap-2 px-5 pb-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex flex-col space-y-1.5 px-5 pt-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}>
{@render children?.()}
</h2>

View File

@@ -0,0 +1,5 @@
export { default as Dialog } from './Dialog.svelte';
export { default as DialogContent } from './DialogContent.svelte';
export { default as DialogHeader } from './DialogHeader.svelte';
export { default as DialogTitle } from './DialogTitle.svelte';
export { default as DialogFooter } from './DialogFooter.svelte';

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils';
interface Props {
class?: string;
orientation?: 'horizontal' | 'vertical';
}
let { class: className = '', orientation = 'horizontal' }: Props = $props();
</script>
<div
role="separator"
class={cn(
'shrink-0 bg-zinc-700',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
></div>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.svelte';

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { cn } from '$lib/utils';
interface Props {
value?: string;
placeholder?: string;
rows?: number;
disabled?: boolean;
class?: string;
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
[key: string]: unknown;
}
let {
value = $bindable(''),
placeholder = '',
rows = 3,
disabled = false,
class: className = '',
onchange,
oninput,
...rest
}: Props = $props();
</script>
<textarea
bind:value
{placeholder}
{rows}
{disabled}
class={cn(
'flex w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 resize-none transition-colors',
'focus:outline-none focus:border-amber-400',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{onchange}
{oninput}
{...rest}
></textarea>

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.svelte';

1
v3/ui/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -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<string, unknown>;
function emit(level: Level, context: string, msg: string, extra?: Extra): void {
const entry: Record<string, unknown> = {
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),
};

View File

@@ -0,0 +1,184 @@
/**
* Server-side MinIO presign helper.
* Calls the backend 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 as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
// 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 backend which holds MinIO credentials.
*/
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
const ext = extFromMime(mimeType);
const res = await backendFetch(`/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<string | null> {
const res = await backendFetch(`/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<string> {
log.debug('minio', 'presigning chapter', { slug, n });
let res: Response;
try {
res = await backendFetch(`/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<string> {
log.debug('minio', 'presigning voice sample', { voice });
let res: Response;
try {
res = await backendFetch(`/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<string> {
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 backendFetch(`/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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
/**
* Valkey-backed presign URL cache (v3).
*
* Replaces the in-process Map from v2. All presign URLs are stored in Valkey
* (Redis-compatible) with native TTL, so:
* - Cache survives UI process restarts.
* - Cache is shared across multiple UI replicas (if scaled horizontally).
* - No manual sweep timer needed — Valkey expires entries automatically.
*
* 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.
*
* Voice-sample URLs use the same cache with key "sample:<voice>".
*
* Connection:
* VALKEY_URL env var (default: redis://valkey:6379)
* ioredis handles reconnection automatically.
*/
import Redis from 'ioredis';
const AUDIO_TTL_S = 50 * 60; // 50 minutes in seconds (Valkey TTL is in seconds)
// Lazily-initialised singleton client.
let _client: Redis | null = null;
function client(): Redis {
if (!_client) {
const url = process.env.VALKEY_URL ?? 'redis://valkey:6379';
_client = new Redis(url, {
// Reconnect automatically with exponential backoff (ioredis default).
// lazyConnect: false means the connection is established immediately.
lazyConnect: false,
// Log connection errors to stderr; do not crash the process.
enableOfflineQueue: true,
maxRetriesPerRequest: 2,
});
_client.on('error', (err: Error) => {
console.error('[presignCache] Valkey error:', err.message);
});
}
return _client;
}
// ── Key helpers ───────────────────────────────────────────────────────────────
/** 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}`;
}
// ── Public API ────────────────────────────────────────────────────────────────
/** Return the cached URL for key, or null if absent / expired. */
export async function get(key: string): Promise<string | null> {
try {
return await client().get(key);
} catch (err) {
console.error('[presignCache] get error:', err);
return null;
}
}
/** Store a presigned URL under key for ttlSeconds seconds. */
export async function set(key: string, url: string, ttlSeconds = AUDIO_TTL_S): Promise<void> {
try {
await client().set(key, url, 'EX', ttlSeconds);
} catch (err) {
console.error('[presignCache] set error:', err);
}
}
/** Invalidate a specific key (e.g. after audio generation to force refresh). */
export async function invalidate(key: string): Promise<void> {
try {
await client().del(key);
} catch (err) {
console.error('[presignCache] invalidate error:', err);
}
}
/**
* Disconnect from Valkey — called on graceful shutdown.
* ioredis queues commands during reconnects; calling quit() drains the queue
* and closes the connection cleanly.
*/
export async function drain(): Promise<void> {
if (_client) {
try {
await _client.quit();
} catch {
// ignore — process is exiting anyway
}
_client = null;
}
}
/**
* Returns the approximate number of keys matching the libnovel presign prefix.
* Used for health/debug only — not called in the hot path.
*/
export async function size(): Promise<number> {
try {
// DBSIZE returns the total key count in the current DB.
// For a precise count of just our keys, use SCAN with a pattern.
const keys = await client().keys('audio:*');
const sampleKeys = await client().keys('sample:*');
return keys.length + sampleKeys.length;
} catch {
return -1;
}
}

View File

@@ -0,0 +1,36 @@
/**
* Backend API helper.
*
* Centralises the BACKEND_URL constant and provides a thin fetch wrapper that:
* - Resolves paths relative to BACKEND_API_URL.
* - Throws 502 on network errors (unreachable backend).
* - Re-throws SvelteKit `error()` objects so callers can still short-circuit.
* - Passes a RequestInit through verbatim so callers keep full control.
*
* Import only from server-side modules (`+server.ts`, `*.server.ts`).
*/
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
/**
* Fetch a path on the backend, throwing a 502 on network failures.
*
* The `path` must start with `/` (e.g. `/api/voices`).
*
* SvelteKit `error()` exceptions are always re-thrown so callers can
* short-circuit correctly inside their own catch blocks.
*/
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(`${BACKEND_URL}${path}`, init);
} catch (e) {
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not reach backend');
}
}

63
v3/ui/src/lib/types.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Shared domain types for the LibNovel UI.
*
* Server-only types (full PocketBase record shapes) live in
* src/lib/server/pocketbase.ts. This file holds the types that are
* safe to import in both server and client code.
*/
// ── Auth / User ──────────────────────────────────────────────────────────────
export interface AuthUser {
id: string;
username: string;
email: string;
role: 'admin' | 'user';
avatarUrl?: string;
}
// ── Books ────────────────────────────────────────────────────────────────────
export interface BookSummary {
id: string;
slug: string;
title: string;
author: string;
coverUrl: string;
status: string;
/** Reading progress 0100, if the user has any. */
progress?: number;
}
export interface ChapterRef {
number: number;
title: string;
}
// ── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
parent_id?: string;
replies?: BookComment[];
}
// ── Audio ────────────────────────────────────────────────────────────────────
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
// ── User settings ─────────────────────────────────────────────────────────────
export interface UserSettings {
voice: string;
speed: number;
autoNext: boolean;
}

Some files were not shown because too many files have changed in this diff Show More