Compare commits
110 Commits
main
...
5d3a1a09ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3a1a09ef | ||
|
|
39ad0d6c11 | ||
|
|
765b37aea3 | ||
|
|
aff6de9b45 | ||
|
|
ec66e86a18 | ||
|
|
9b7cdad71a | ||
|
|
8f0a2f7e92 | ||
|
|
08d4718245 | ||
|
|
60a9540ef7 | ||
|
|
76d616a308 | ||
|
|
e723459507 | ||
|
|
b3358ac1d2 | ||
|
|
c0d33720e9 | ||
|
|
a5c603e7a6 | ||
|
|
219d4fb214 | ||
|
|
cec0dfe64a | ||
|
|
54616b82d7 | ||
|
|
ce5db37226 | ||
|
|
60bc8e5749 | ||
|
|
b4be0803aa | ||
|
|
12eca865ce | ||
|
|
589f39b49e | ||
|
|
53083429a0 | ||
|
|
70c8db28f9 | ||
|
|
1d00fd4e2e | ||
|
|
a54d8d43aa | ||
|
|
97e7a8dc02 | ||
|
|
fb6b364382 | ||
|
|
7b48707cd9 | ||
|
|
b0547c1b43 | ||
|
|
acbfafb8cd | ||
|
|
c8e0cf2813 | ||
|
|
3899a96576 | ||
|
|
1e7f396b2d | ||
|
|
0eee2eedf3 | ||
|
|
80da1bb3e2 | ||
|
|
9f3e895fa8 | ||
|
|
cf0c0dfaaf | ||
|
|
0402c408e4 | ||
|
|
d14644238f | ||
|
|
8de374cd35 | ||
|
|
82186cfd6d | ||
|
|
b87e758303 | ||
|
|
901b18ee13 | ||
|
|
034e670795 | ||
|
|
0d7b985469 | ||
|
|
53af7515a3 | ||
|
|
11a846d043 | ||
|
|
bf2ffa54db | ||
|
|
fe204598a2 | ||
|
|
9906c7d862 | ||
|
|
06feb91f4f | ||
|
|
5a7751e6d1 | ||
|
|
555973c053 | ||
|
|
c2d6ce1c5b | ||
|
|
8edad54b10 | ||
|
|
48d8fdb6b9 | ||
|
|
1b05b6ebc6 | ||
|
|
cabdd3ffdd | ||
|
|
f80b83309a | ||
|
|
49ba2c27c2 | ||
|
|
353d7397eb | ||
|
|
89ff90629f | ||
|
|
f6febfdb5e | ||
|
|
2c43907e34 | ||
|
|
0e868506ca | ||
|
|
1b234754e8 | ||
|
|
041099598b | ||
|
|
333c8ad868 | ||
|
|
d16ae00537 | ||
|
|
d16313bb6c | ||
|
|
1bab7028c6 | ||
|
|
6520fb9a50 | ||
|
|
7acf04fb9f | ||
|
|
c2bcb2b0a6 | ||
|
|
cfd893d24b | ||
|
|
cff0c78b4f | ||
|
|
d89cefe975 | ||
|
|
a0344b36d7 | ||
|
|
af3c487afb | ||
|
|
b8d4d94b18 | ||
|
|
56bf4dde22 | ||
|
|
2f0857be45 | ||
|
|
bf5774d8d0 | ||
|
|
5131ae0bc4 | ||
|
|
9fa0776258 | ||
|
|
f265d9d020 | ||
|
|
3c26dfe2c0 | ||
|
|
1820fa7303 | ||
|
|
38e400a4c7 | ||
|
|
cb90771248 | ||
|
|
59b1cfab1d | ||
|
|
f95ad3ed29 | ||
|
|
e4c4f8de66 | ||
|
|
4f84bd29c9 | ||
|
|
6bf79ab392 | ||
|
|
4ae6f0ab42 | ||
|
|
33e2a4dc01 | ||
|
|
cb4be0848f | ||
|
|
2f948f2a50 | ||
|
|
baab66823d | ||
|
|
11d2eaa0e5 | ||
|
|
9c115f00c4 | ||
|
|
5ac89da513 | ||
|
|
af86c6f96f | ||
|
|
da4a182f85 | ||
|
|
18e76c9668 | ||
|
|
9add9033b9 | ||
|
|
66d8481637 | ||
|
|
7f92a58fd7 |
50
.env.example
50
.env.example
@@ -1,6 +1,26 @@
|
||||
# libnovel scraper — environment overrides
|
||||
# Copy to .env and adjust values; do NOT commit this file with real secrets.
|
||||
|
||||
# ── Service ports (host-side) ─────────────────────────────────────────────────
|
||||
# Port the scraper HTTP API listens on (default 8080)
|
||||
SCRAPER_PORT=8080
|
||||
|
||||
# Port PocketBase listens on (default 8090)
|
||||
POCKETBASE_PORT=8090
|
||||
|
||||
# Port MinIO S3 API listens on (default 9000)
|
||||
MINIO_PORT=9000
|
||||
|
||||
# Port MinIO web console listens on (default 9001)
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# Port Browserless Chrome listens on (default 3030)
|
||||
BROWSERLESS_PORT=3030
|
||||
|
||||
# Port the SvelteKit UI listens on (default 3000)
|
||||
UI_PORT=3000
|
||||
|
||||
# ── Browserless ───────────────────────────────────────────────────────────────
|
||||
# Browserless API token (leave empty to disable auth)
|
||||
BROWSERLESS_TOKEN=
|
||||
|
||||
@@ -19,10 +39,7 @@ ERROR_ALERT_URL=
|
||||
# Which Browserless strategy the scraper uses: content | scrape | cdp | direct
|
||||
BROWSERLESS_STRATEGY=direct
|
||||
|
||||
# Strategy for URL retrieval (chapter list). Uses browserless content strategy by default.
|
||||
# Set to direct to use plain HTTP, or content/scrape/cdp for browserless.
|
||||
BROWSERLESS_URL_STRATEGY=content
|
||||
|
||||
# ── Scraper ───────────────────────────────────────────────────────────────────
|
||||
# Chapter worker goroutines (0 = NumCPU inside the container)
|
||||
SCRAPER_WORKERS=0
|
||||
|
||||
@@ -39,3 +56,28 @@ KOKORO_URL=http://kokoro:8880
|
||||
# Single voices: af_bella, af_sky, af_heart, am_adam, …
|
||||
# Mixed voices: af_bella+af_sky or af_bella(2)+af_sky(1) (weighted blend)
|
||||
KOKORO_VOICE=af_bella
|
||||
|
||||
# ── MinIO / S3 object storage ─────────────────────────────────────────────────
|
||||
MINIO_ROOT_USER=admin
|
||||
MINIO_ROOT_PASSWORD=changeme123
|
||||
MINIO_BUCKET_CHAPTERS=libnovel-chapters
|
||||
MINIO_BUCKET_AUDIO=libnovel-audio
|
||||
|
||||
# ── PocketBase ────────────────────────────────────────────────────────────────
|
||||
# Admin credentials (used by scraper + UI server-side)
|
||||
POCKETBASE_ADMIN_EMAIL=admin@libnovel.local
|
||||
POCKETBASE_ADMIN_PASSWORD=changeme123
|
||||
|
||||
# ── SvelteKit UI ─────────────────────────────────────────────────────────────
|
||||
# Internal URL the SvelteKit server uses to reach the scraper API.
|
||||
# In docker-compose this is http://scraper:8080 (wired automatically).
|
||||
# Override here only if running the UI outside of docker-compose.
|
||||
SCRAPER_API_URL=http://localhost:8080
|
||||
|
||||
# Internal URL the SvelteKit server uses to reach PocketBase.
|
||||
# In docker-compose this is http://pocketbase:8090 (wired automatically).
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# Public MinIO URL reachable from the browser (for audio/presigned URLs).
|
||||
# In production, point this at your MinIO reverse-proxy or CDN domain.
|
||||
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
name: CI
|
||||
name: CI / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/**"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/**"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -69,36 +69,8 @@ jobs:
|
||||
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: scraper-linux-amd64
|
||||
path: scraper/bin/scraper
|
||||
retention-days: 7
|
||||
|
||||
# ── docker build (& push) ────────────────────────────────────────────────────
|
||||
# Uncomment once the runner has Docker available and a registry is configured.
|
||||
#
|
||||
# docker:
|
||||
# name: Docker
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [lint, test]
|
||||
# # Only push images on commits to the default branch, not on PRs.
|
||||
# # if: github.event_name == 'push'
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Log in to Gitea registry
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# registry: gitea.kalekber.cc
|
||||
# username: ${{ secrets.REGISTRY_USER }}
|
||||
# password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
#
|
||||
# - name: Build and push
|
||||
# uses: docker/build-push-action@v5
|
||||
# with:
|
||||
# context: ./scraper
|
||||
# push: true
|
||||
# tags: |
|
||||
# gitea.kalekber.cc/kamil/libnovel:latest
|
||||
# gitea.kalekber.cc/kamil/libnovel:${{ gitea.sha }}
|
||||
40
.gitea/workflows/ci-ui.yaml
Normal file
40
.gitea/workflows/ci-ui.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: CI / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
134
.gitea/workflows/deploy.yaml
Normal file
134
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- "ui/**"
|
||||
- "docker-compose.yml"
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
# tRPC API helper notes:
|
||||
# Mutations: POST /api/trpc/<router>.<procedure>
|
||||
# Body: {"0":{"json":{...input...}}}
|
||||
# Header: x-api-key: <token>
|
||||
# Queries: GET /api/trpc/<router>.<procedure>?batch=1&input={"0":{"json":{...input...}}}
|
||||
# Header: x-api-key: <token>
|
||||
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
|
||||
|
||||
jobs:
|
||||
# ── production deploy (main/master only) ─────────────────────────────────────
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
||||
steps:
|
||||
- name: Redeploy production stack
|
||||
run: |
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.redeploy" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"0":{"json":{"composeId":"${{ secrets.DOKPLOY_COMPOSE_ID }}"}}}')
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
[ "$HTTP_CODE" = "200" ] || { echo "Redeploy failed"; exit 1; }
|
||||
|
||||
# ── preview deploy (feature branches) ────────────────────────────────────────
|
||||
deploy-preview:
|
||||
name: Deploy Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'push' &&
|
||||
github.ref != 'refs/heads/main' &&
|
||||
github.ref != 'refs/heads/master'
|
||||
steps:
|
||||
- name: Sanitize branch name
|
||||
id: branch
|
||||
run: |
|
||||
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
|
||||
SUFFIX=$(echo "${{ github.ref_name }}" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed 's/[^a-z0-9]/-/g' \
|
||||
| cut -c1-20 \
|
||||
| sed 's/-*$//')
|
||||
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "Preview suffix: $SUFFIX"
|
||||
|
||||
- name: Create or redeploy isolated preview stack
|
||||
run: |
|
||||
# compose.isolatedDeployment creates a new isolated copy of the compose stack
|
||||
# suffixed with the branch name. If the stack already exists it redeploys it.
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.isolatedDeployment" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"0\":{\"json\":{\"composeId\":\"${{ secrets.DOKPLOY_COMPOSE_ID }}\",\"suffix\":\"${{ steps.branch.outputs.suffix }}\"}}}")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
[ "$HTTP_CODE" = "200" ] || { echo "Preview deploy failed"; exit 1; }
|
||||
|
||||
# ── cleanup preview on PR close ───────────────────────────────────────────────
|
||||
cleanup-preview:
|
||||
name: Cleanup Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
steps:
|
||||
- name: Sanitize branch name
|
||||
id: branch
|
||||
run: |
|
||||
SUFFIX=$(echo "${{ github.head_ref }}" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed 's/[^a-z0-9]/-/g' \
|
||||
| cut -c1-20 \
|
||||
| sed 's/-*$//')
|
||||
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "Cleaning up preview suffix: $SUFFIX"
|
||||
|
||||
- name: Search for preview compose stack by appName
|
||||
id: find
|
||||
run: |
|
||||
# compose.search is a tRPC query (GET). We search by appName pattern.
|
||||
# appName is set by Dokploy as "<base-appName>-<suffix>" for isolated deployments.
|
||||
INPUT=$(python3 -c "import json,sys; print(json.dumps({'0':{'json':{'appName':'libnovel-${{ steps.branch.outputs.suffix }}','limit':5,'offset':0}}}))")
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -G \
|
||||
"${{ secrets.DOKPLOY_URL }}/api/trpc/compose.search" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
--data-urlencode "batch=1" \
|
||||
--data-urlencode "input=$INPUT")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
# Extract the first composeId from the JSON response array
|
||||
COMPOSE_ID=$(echo "$BODY" | python3 -c "
|
||||
import json,sys
|
||||
data = json.load(sys.stdin)
|
||||
items = data[0]['result']['data']['json']['items']
|
||||
print(items[0]['composeId'] if items else '')
|
||||
" 2>/dev/null || echo "")
|
||||
echo "composeId=$COMPOSE_ID" >> $GITHUB_OUTPUT
|
||||
echo "Found composeId: $COMPOSE_ID"
|
||||
|
||||
- name: Delete preview stack
|
||||
if: steps.find.outputs.composeId != ''
|
||||
run: |
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.delete" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"0\":{\"json\":{\"composeId\":\"${{ steps.find.outputs.composeId }}\",\"deleteVolumes\":true}}}")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
[ "$HTTP_CODE" = "200" ] || { echo "Delete failed"; exit 1; }
|
||||
69
.gitea/workflows/release-ui.yaml
Normal file
69
.gitea/workflows/release-ui.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Release / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── docker build & push ──────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.kalekber.cc
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gitea.kalekber.cc/kamil/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./ui
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
||||
scraper/bin/
|
||||
scraper/scraper
|
||||
|
||||
# ── Scraped output (large, machine-generated) ──────────────────────────────────
|
||||
|
||||
|
||||
170
AGENTS.md
170
AGENTS.md
@@ -1,28 +1,43 @@
|
||||
# libnovel Project
|
||||
|
||||
Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI.
|
||||
Go web scraper for novelfire.net with TTS support via Kokoro-FastAPI. Structured data in PocketBase, binary blobs (chapters, audio, browse snapshots) in MinIO. SvelteKit frontend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scraper/
|
||||
├── cmd/scraper/main.go # Entry point: 'run' (one-shot) and 'serve' (HTTP server)
|
||||
├── cmd/scraper/main.go # Entry point: run | refresh | serve | save-browse
|
||||
├── internal/
|
||||
│ ├── orchestrator/orchestrator.go # Coordinates catalogue walk, metadata extraction, chapter scraping
|
||||
│ ├── browser/ # Browser client (content/scrape/cdp strategies) via Browserless
|
||||
│ ├── novelfire/scraper.go # novelfire.net specific scraping logic
|
||||
│ ├── server/server.go # HTTP API (POST /scrape, POST /scrape/book)
|
||||
│ ├── writer/writer.go # File writer (metadata.yaml, chapter .md files)
|
||||
│ └── scraper/interfaces.go # NovelScraper interface definition
|
||||
└── static/books/ # Output directory for scraped content
|
||||
│ ├── orchestrator/orchestrator.go # Catalogue walk → per-book metadata goroutines → chapter worker pool
|
||||
│ ├── browser/ # BrowserClient interface + direct HTTP (production) + Browserless variants
|
||||
│ ├── novelfire/scraper.go # novelfire.net scraping (catalogue, metadata, chapters, ranking)
|
||||
│ ├── server/ # HTTP API server (server.go + 6 handler files)
|
||||
│ │ ├── server.go # Server struct, route registration, ListenAndServe
|
||||
│ │ ├── handlers_scrape.go # POST /scrape, /scrape/book, /scrape/book/range; job status/tasks
|
||||
│ │ ├── handlers_browse.go # GET /api/browse, /api/search, /api/cover — MinIO-cached browse pages
|
||||
│ │ ├── handlers_preview.go # GET /api/book-preview, /api/chapter-text-preview — live scrape, no store writes
|
||||
│ │ ├── handlers_audio.go # POST /api/audio, GET /api/audio-proxy, voice samples, presign
|
||||
│ │ ├── handlers_progress.go # GET/POST/DELETE /api/progress
|
||||
│ │ ├── handlers_ranking.go # GET /api/ranking, /api/cover
|
||||
│ │ └── helpers.go # stripMarkdown, hardcoded voice list fallback
|
||||
│ ├── storage/ # Persistence layer (PocketBase + MinIO)
|
||||
│ │ ├── store.go # Store interface — single abstraction for server + orchestrator
|
||||
│ │ ├── hybrid.go # HybridStore: routes structured data → PocketBase, blobs → MinIO
|
||||
│ │ ├── pocketbase.go # PocketBase REST admin client (7 collections, auth, schema bootstrap)
|
||||
│ │ ├── minio.go # MinIO client (3 buckets: chapters, audio, browse)
|
||||
│ │ └── coverutil.go # Best-effort cover image downloader → browse bucket
|
||||
│ └── scraper/
|
||||
│ ├── interfaces.go # NovelScraper interface + domain types (BookMeta, ChapterRef, etc.)
|
||||
│ └── htmlutil/htmlutil.go # HTML parsing helpers (NodeToMarkdown, ResolveURL, etc.)
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Orchestrator**: Manages concurrency - catalogue streaming → per-book metadata goroutines → chapter worker pool
|
||||
- **Browser Client**: 3 strategies (content/scrape/cdp) via Browserless Chrome container
|
||||
- **Writer**: Writes metadata.yaml and chapter markdown files to `static/books/{slug}/vol-0/1-50/`
|
||||
- **Server**: HTTP API with async scrape jobs, UI for browsing books/chapters, chapter-text endpoint for TTS
|
||||
- **Orchestrator**: Catalogue stream → per-book goroutines (metadata + chapter list) → shared chapter work channel → N worker goroutines (chapter text). Scrape jobs tracked in PocketBase `scraping_tasks`.
|
||||
- **Storage**: `HybridStore` implements the `Store` interface. PocketBase holds structured records (`books`, `chapters_idx`, `ranking`, `progress`, `audio_cache`, `app_users`, `scraping_tasks`). MinIO holds blobs (chapter markdown, audio MP3s, browse HTML snapshots, cover images).
|
||||
- **Browser Client**: Production uses `NewDirectHTTPClient` (plain HTTP, no Browserless). Browserless variants (content/scrape/cdp) exist in `browser/` but are only wired for the `save-browse` subcommand.
|
||||
- **Preview**: `GET /api/book-preview/{slug}` scrapes metadata + chapter list live without persisting anything — used when a book is not yet in the library. On first visit, metadata and chapter index are auto-saved to PocketBase in the background.
|
||||
- **Server**: 24 HTTP endpoints. Async scrape jobs (mutex, 409 on concurrent), in-flight dedup for audio generation, MinIO-backed browse page cache with mem-cache fallback.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -30,60 +45,127 @@ scraper/
|
||||
# Build
|
||||
cd scraper && go build -o bin/scraper ./cmd/scraper
|
||||
|
||||
# One-shot scrape (full catalogue)
|
||||
# Full catalogue scrape (one-shot)
|
||||
./bin/scraper run
|
||||
|
||||
# Single book
|
||||
./bin/scraper run --url https://novelfire.net/book/xxx
|
||||
|
||||
# Re-scrape a book already in the DB (uses stored source_url)
|
||||
./bin/scraper refresh <slug>
|
||||
|
||||
# HTTP server
|
||||
./bin/scraper serve
|
||||
|
||||
# Tests
|
||||
# Capture browse pages to MinIO via SingleFile CLI (requires SINGLEFILE_PATH + BROWSERLESS_URL)
|
||||
./bin/scraper save-browse
|
||||
|
||||
# Tests (unit only — integration tests require live services)
|
||||
cd scraper && go test ./... -short
|
||||
|
||||
# All tests (requires MinIO + PocketBase + Browserless)
|
||||
cd scraper && go test ./...
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Scraper (Go)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| BROWSERLESS_URL | Browserless Chrome endpoint | http://localhost:3030 |
|
||||
| BROWSERLESS_STRATEGY | content \| scrape \| cdp | content |
|
||||
| SCRAPER_WORKERS | Chapter goroutines | NumCPU |
|
||||
| SCRAPER_STATIC_ROOT | Output directory | ./static/books |
|
||||
| SCRAPER_HTTP_ADDR | HTTP listen address | :8080 |
|
||||
| KOKORO_URL | Kokoro TTS endpoint | http://localhost:8880 |
|
||||
| KOKORO_VOICE | Default TTS voice | af_bella |
|
||||
| LOG_LEVEL | debug \| info \| warn \| error | info |
|
||||
| `LOG_LEVEL` | `debug\|info\|warn\|error` | `info` |
|
||||
| `SCRAPER_HTTP_ADDR` | HTTP listen address | `:8080` |
|
||||
| `SCRAPER_WORKERS` | Chapter goroutines | `NumCPU` |
|
||||
| `SCRAPER_TIMEOUT` | Per-request HTTP timeout (seconds) | `90` |
|
||||
| `KOKORO_URL` | Kokoro-FastAPI TTS base URL | `https://kokoro.kalekber.cc` |
|
||||
| `KOKORO_VOICE` | Default TTS voice | `af_bella` |
|
||||
| `MINIO_ENDPOINT` | MinIO S3 API host:port | `localhost:9000` |
|
||||
| `MINIO_PUBLIC_ENDPOINT` | Public MinIO endpoint for presigned URLs | `""` |
|
||||
| `MINIO_ACCESS_KEY` | MinIO access key | `admin` |
|
||||
| `MINIO_SECRET_KEY` | MinIO secret key | `changeme123` |
|
||||
| `MINIO_USE_SSL` | TLS for internal MinIO connection | `false` |
|
||||
| `MINIO_PUBLIC_USE_SSL` | TLS for public presigned URL endpoint | `true` |
|
||||
| `MINIO_BUCKET_CHAPTERS` | Chapter markdown bucket | `libnovel-chapters` |
|
||||
| `MINIO_BUCKET_AUDIO` | Audio MP3 bucket | `libnovel-audio` |
|
||||
| `MINIO_BUCKET_BROWSE` | Browse HTML + cover image bucket | `libnovel-browse` |
|
||||
| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` |
|
||||
| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` |
|
||||
| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` |
|
||||
| `BROWSERLESS_URL` | Browserless WS endpoint (save-browse only) | `http://localhost:3030` |
|
||||
| `SINGLEFILE_PATH` | SingleFile CLI binary path (save-browse only) | `single-file` |
|
||||
|
||||
### UI (SvelteKit)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `AUTH_SECRET` | HMAC signing secret for auth tokens | `dev_secret_change_in_production` |
|
||||
| `SCRAPER_API_URL` | Internal URL of the Go scraper | `http://localhost:8080` |
|
||||
| `POCKETBASE_URL` | PocketBase base URL | `http://localhost:8090` |
|
||||
| `POCKETBASE_ADMIN_EMAIL` | PocketBase admin email | `admin@libnovel.local` |
|
||||
| `POCKETBASE_ADMIN_PASSWORD` | PocketBase admin password | `changeme123` |
|
||||
| `PUBLIC_MINIO_PUBLIC_URL` | Browser-visible MinIO URL (presigned links) | `http://localhost:9000` |
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker-compose up -d # Starts browserless, kokoro, scraper
|
||||
docker-compose up -d # Starts: minio, minio-init, pocketbase, pb-init, scraper, ui
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
| Service | Port(s) | Role |
|
||||
|---------|---------|------|
|
||||
| `minio` | `9000` (S3 API), `9001` (console) | Object storage |
|
||||
| `minio-init` | — | One-shot bucket creation then exits |
|
||||
| `pocketbase` | `8090` | Structured data store |
|
||||
| `pb-init` | — | One-shot PocketBase collection bootstrap then exits |
|
||||
| `scraper` | `8080` | Go scraper HTTP API |
|
||||
| `ui` | `5252` → internal `3000` | SvelteKit frontend |
|
||||
|
||||
Kokoro and Browserless are **external services** — not in docker-compose.
|
||||
|
||||
## HTTP API Endpoints (Go scraper)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/health` | Liveness probe |
|
||||
| `POST` | `/scrape` | Enqueue full catalogue scrape |
|
||||
| `POST` | `/scrape/book` | Enqueue single-book scrape `{url}` |
|
||||
| `POST` | `/scrape/book/range` | Enqueue range scrape `{url, from, to?}` |
|
||||
| `GET` | `/api/scrape/status` | Current scrape job status |
|
||||
| `GET` | `/api/scrape/tasks` | All scrape task records |
|
||||
| `GET` | `/api/browse` | Browse novelfire catalogue (MinIO-cached) |
|
||||
| `GET` | `/api/search` | Search local + remote `?q=` |
|
||||
| `GET` | `/api/ranking` | Ranking list |
|
||||
| `GET` | `/api/cover/{domain}/{slug}` | Proxy cover image from MinIO |
|
||||
| `GET` | `/api/book-preview/{slug}` | Live metadata + chapter list (no store write) |
|
||||
| `GET` | `/api/chapter-text-preview/{slug}/{n}` | Live chapter text (no store write) |
|
||||
| `POST` | `/api/reindex/{slug}` | Rebuild chapters_idx from MinIO |
|
||||
| `GET` | `/api/chapter-text/{slug}/{n}` | Chapter text (markdown stripped) |
|
||||
| `POST` | `/api/audio/{slug}/{n}` | Trigger Kokoro TTS generation |
|
||||
| `GET` | `/api/audio-proxy/{slug}/{n}` | Proxy generated audio |
|
||||
| `POST` | `/api/audio/voice-samples` | Pre-generate voice samples |
|
||||
| `GET` | `/api/voices` | List available Kokoro voices |
|
||||
| `GET` | `/api/presign/chapter/{slug}/{n}` | Presigned MinIO URL for chapter |
|
||||
| `GET` | `/api/presign/audio/{slug}/{n}` | Presigned MinIO URL for audio |
|
||||
| `GET` | `/api/presign/voice-sample/{voice}` | Presigned MinIO URL for voice sample |
|
||||
| `GET` | `/api/progress` | Get reading progress (session-scoped) |
|
||||
| `POST` | `/api/progress/{slug}` | Set reading progress |
|
||||
| `DELETE` | `/api/progress/{slug}` | Delete reading progress |
|
||||
|
||||
## Code Patterns
|
||||
|
||||
- Uses `log/slog` for structured logging
|
||||
- Context-based cancellation throughout
|
||||
- Worker pool pattern in orchestrator (channel + goroutines)
|
||||
- Mutex for single async job (409 on concurrent scrape requests)
|
||||
- `log/slog` for structured logging throughout
|
||||
- Context-based cancellation on all network calls and goroutines
|
||||
- Worker pool pattern in orchestrator (buffered channel + WaitGroup)
|
||||
- Single async scrape job enforced by mutex; 409 on concurrent requests; job state persisted to `scraping_tasks` in PocketBase
|
||||
- `Store` interface decouples all persistence — pass it around, never touch MinIO/PocketBase clients directly outside `storage/`
|
||||
- Auth: custom HMAC-signed token (`userId:username:role.<sig>`) in `libnovel_auth` cookie; signed with `AUTH_SECRET`
|
||||
|
||||
## AI Context Tips
|
||||
|
||||
- Primary files to modify: `orchestrator.go`, `server.go`, `scraper.go`, `browser/*.go`
|
||||
- To add new source: implement `NovelScraper` interface from `internal/scraper/interfaces.go`
|
||||
- Skip `static/` directory - generated content, not source
|
||||
|
||||
## Speed Up AI Sessions (Optional)
|
||||
|
||||
For faster AI context loading, use **Context7** (free, local indexing):
|
||||
|
||||
```bash
|
||||
# Install and index once
|
||||
npx @context7/cli@latest index --path . --ignore .aiignore
|
||||
|
||||
# After first run, AI tools will query the index instead of re-scanning files
|
||||
```
|
||||
|
||||
VSCode extension: https://marketplace.visualstudio.com/items?itemName=context7.context7
|
||||
- **Primary files to modify**: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go`
|
||||
- **To add a new scrape source**: implement `NovelScraper` from `internal/scraper/interfaces.go`
|
||||
- **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()`
|
||||
- **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go`
|
||||
- **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes
|
||||
|
||||
@@ -1,82 +1,157 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
# ─── Browserless ────────────────────────────────────────────────────────────
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
container_name: libnovel-browserless
|
||||
# ─── MinIO (object storage for chapter .md files + audio cache) ─────────────
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
#container_name: libnovel-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
# Set a token to lock down the endpoint; the scraper reads it via
|
||||
# BROWSERLESS_TOKEN below.
|
||||
TOKEN: "${BROWSERLESS_TOKEN:-}"
|
||||
# Allow up to 10 concurrent browser sessions.
|
||||
CONCURRENT: "${BROWSERLESS_CONCURRENT:-10}"
|
||||
# Queue up to 100 requests before returning 429.
|
||||
QUEUED: "${BROWSERLESS_QUEUED:-100}"
|
||||
# Per-session timeout in ms.
|
||||
TIMEOUT: "${BROWSERLESS_TIMEOUT:-60000}"
|
||||
# Optional webhook URL for Browserless error alerts.
|
||||
ERROR_ALERT_URL: "${ERROR_ALERT_URL:-}"
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "3030:3000"
|
||||
# Shared memory is required for Chrome.
|
||||
shm_size: "2gb"
|
||||
- "${MINIO_PORT:-9000}:9000" # S3 API
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/json/version"]
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Kokoro-FastAPI (TTS) ────────────────────────────────────────────────────
|
||||
# CPU image; swap for ghcr.io/remsky/kokoro-fastapi-gpu:latest on NVIDIA hosts.
|
||||
# Models are baked in — no volume mount required for the default voice set.
|
||||
kokoro:
|
||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
||||
container_name: libnovel-kokoro
|
||||
# ─── MinIO bucket initialisation ─────────────────────────────────────────────
|
||||
# Runs once to create the default buckets and then exits.
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
#container_name: libnovel-minio-init
|
||||
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-browse;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
|
||||
# ─── PocketBase (auth + structured data: books, chapters index, ranking, progress) ──
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
#container_name: libnovel-pocketbase
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Auto-create superuser on first boot (used by entrypoint.sh)
|
||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "8880:8880"
|
||||
- "${POCKETBASE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8880/health"]
|
||||
interval: 15s
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── PocketBase collection bootstrap ────────────────────────────────────────
|
||||
# One-shot init container: creates all required collections via the admin API
|
||||
# and exits. Idempotent — safe to run on every `docker compose up`.
|
||||
pb-init:
|
||||
image: alpine:3.19
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
volumes:
|
||||
- ./scripts/pb-init.sh:/pb-init.sh:ro
|
||||
entrypoint: ["sh", "/pb-init.sh"]
|
||||
|
||||
# ─── Scraper ─────────────────────────────────────────────────────────────────
|
||||
scraper:
|
||||
build:
|
||||
context: ./scraper
|
||||
dockerfile: Dockerfile
|
||||
container_name: libnovel-scraper
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
kokoro:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BROWSERLESS_URL: "http://browserless:3000"
|
||||
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN:-}"
|
||||
# content | scrape | cdp | direct — swap to test different strategies.
|
||||
BROWSERLESS_STRATEGY: "${BROWSERLESS_STRATEGY:-direct}"
|
||||
# Strategy for URL retrieval (chapter list). Default: content (browserless)
|
||||
BROWSERLESS_URL_STRATEGY: "${BROWSERLESS_URL_STRATEGY:-content}"
|
||||
# 0 → defaults to NumCPU inside the container.
|
||||
SCRAPER_WORKERS: "${SCRAPER_WORKERS:-0}"
|
||||
SCRAPER_STATIC_ROOT: "/app/static/books"
|
||||
SCRAPER_HTTP_ADDR: ":8080"
|
||||
LOG_LEVEL: "debug"
|
||||
# Kokoro-FastAPI TTS endpoint.
|
||||
KOKORO_URL: "${KOKORO_URL:-http://localhost:8880}"
|
||||
KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
|
||||
# MinIO / S3 object storage
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
MINIO_USE_SSL: "false"
|
||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
||||
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
|
||||
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}"
|
||||
# SingleFile CLI path for save-browse subcommand
|
||||
SINGLEFILE_PATH: "${SINGLEFILE_PATH:-single-file}"
|
||||
# PocketBase
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- static_books:/app/static/books
|
||||
- "${SCRAPER_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ─── SvelteKit UI ────────────────────────────────────────────────────────────
|
||||
ui:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
# container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
scraper:
|
||||
condition: service_healthy
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SCRAPER_API_URL: "http://scraper:8080"
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
||||
ports:
|
||||
- "${UI_PORT:-5252}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
static_books:
|
||||
minio_data:
|
||||
pb_data:
|
||||
|
||||
143
justfile
Normal file
143
justfile
Normal file
@@ -0,0 +1,143 @@
|
||||
# justfile — libnovel-v2 task runner
|
||||
# Install just: https://just.systems
|
||||
|
||||
scraper_dir := "scraper"
|
||||
ui_dir := "ui"
|
||||
|
||||
# ─── Build ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Build the scraper binary
|
||||
build:
|
||||
cd {{scraper_dir}} && go build -o bin/scraper ./cmd/scraper
|
||||
|
||||
# Build and verify all Go packages compile cleanly
|
||||
build-all:
|
||||
cd {{scraper_dir}} && go build ./...
|
||||
|
||||
# ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Run unit tests only (no integration services required)
|
||||
test:
|
||||
cd {{scraper_dir}} && go test ./...
|
||||
|
||||
# Run integration tests (requires MinIO, PocketBase, optional Browserless)
|
||||
# Override env vars as needed, e.g.:
|
||||
# just test-integration MINIO_ENDPOINT=localhost:9000
|
||||
test-integration:
|
||||
cd {{scraper_dir}} && go test -v -tags integration -timeout 600s ./...
|
||||
|
||||
# Run unit + integration tests
|
||||
test-all: test test-integration
|
||||
|
||||
# Run a specific package's integration tests, e.g.:
|
||||
# just test-pkg internal/storage
|
||||
test-pkg pkg:
|
||||
cd {{scraper_dir}} && go test -v -tags integration -timeout 600s ./{{pkg}}/...
|
||||
|
||||
# Run end-to-end tests against live services.
|
||||
# All services must be running first (docker compose up -d or just e2e-up).
|
||||
# Override env vars as needed, e.g.:
|
||||
# just test-e2e SCRAPER_URL=http://localhost:8080 KOKORO_VOICE=af_bella
|
||||
test-e2e \
|
||||
browserless_url="http://localhost:3030" \
|
||||
minio_endpoint="localhost:9000" \
|
||||
pocketbase_url="http://localhost:8090" \
|
||||
scraper_url="http://localhost:8080":
|
||||
cd {{scraper_dir}} && \
|
||||
BROWSERLESS_URL={{browserless_url}} \
|
||||
MINIO_ENDPOINT={{minio_endpoint}} \
|
||||
POCKETBASE_URL={{pocketbase_url}} \
|
||||
SCRAPER_URL={{scraper_url}} \
|
||||
go test -v -tags integration -timeout 900s ./internal/e2e/...
|
||||
|
||||
# Start all services required for e2e tests, then run them
|
||||
e2e: up test-e2e
|
||||
|
||||
# ─── Code quality ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Run go vet on all packages (including integration build tag)
|
||||
lint:
|
||||
cd {{scraper_dir}} && go vet ./...
|
||||
cd {{scraper_dir}} && go vet -tags integration ./...
|
||||
|
||||
# ─── UI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Type-check the SvelteKit UI
|
||||
ui-check:
|
||||
cd {{ui_dir}} && npx svelte-check
|
||||
|
||||
# Start the SvelteKit dev server
|
||||
ui-dev:
|
||||
cd {{ui_dir}} && npm run dev
|
||||
|
||||
# Install UI dependencies
|
||||
ui-install:
|
||||
cd {{ui_dir}} && npm install
|
||||
|
||||
# Build the UI for production
|
||||
ui-build:
|
||||
cd {{ui_dir}} && npm run build
|
||||
|
||||
# ─── Docker Compose ───────────────────────────────────────────────────────────
|
||||
|
||||
# Start all services (browserless, kokoro, scraper, minio, pocketbase)
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
# Stop all services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Tail logs for all services
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Tail logs for a specific service, e.g.: just logs-service scraper
|
||||
logs-service service:
|
||||
docker compose logs -f {{service}}
|
||||
|
||||
# Rebuild and restart a specific service
|
||||
restart service:
|
||||
docker compose up -d --build {{service}}
|
||||
|
||||
# ─── Local dev: individual services ──────────────────────────────────────────
|
||||
|
||||
# Start only PocketBase (for local storage testing)
|
||||
pb-up:
|
||||
docker compose up -d pocketbase
|
||||
|
||||
# Start only MinIO (for local storage testing)
|
||||
minio-up:
|
||||
docker compose up -d minio
|
||||
|
||||
# Start only Browserless (for local scraping tests)
|
||||
browserless-up:
|
||||
docker compose up -d browserless
|
||||
|
||||
# Start storage backends only (MinIO + PocketBase)
|
||||
storage-up:
|
||||
docker compose up -d minio pocketbase
|
||||
|
||||
# ─── Convenience ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Show status of all docker compose services
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
# Remove all stopped containers and unused images
|
||||
prune:
|
||||
docker compose down --remove-orphans
|
||||
docker image prune -f
|
||||
|
||||
# One-shot scrape of the full catalogue (requires services to be running)
|
||||
scrape-run: build
|
||||
cd {{scraper_dir}} && ./bin/scraper run
|
||||
|
||||
# One-shot scrape of a single book URL, e.g.:
|
||||
# just scrape-book https://novelfire.net/book/my-novel
|
||||
scrape-book url: build
|
||||
cd {{scraper_dir}} && ./bin/scraper run --url {{url}}
|
||||
|
||||
# Start the HTTP server
|
||||
serve: build
|
||||
cd {{scraper_dir}} && ./bin/scraper serve
|
||||
4
scraper/.dockerignore
Normal file
4
scraper/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
static/
|
||||
*.md
|
||||
.git
|
||||
@@ -13,9 +13,10 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o /scraper ./cmd/scraper
|
||||
|
||||
# ── Runtime stage ──────────────────────────────────────────────────────────────
|
||||
FROM alpine:3.20
|
||||
FROM alpine:3.21
|
||||
|
||||
# ca-certificates is required for HTTPS requests to novelfire.net.
|
||||
# ca-certificates: HTTPS to novelfire.net
|
||||
# tzdata: timezone data
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
@@ -31,8 +32,6 @@ RUN chown -R scraper:scraper /app
|
||||
USER scraper
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
ENV BROWSERLESS_URL=http://browserless:3030
|
||||
ENV BROWSERLESS_STRATEGY=content
|
||||
ENV SCRAPER_WORKERS=0
|
||||
ENV SCRAPER_STATIC_ROOT=/app/static/books
|
||||
ENV SCRAPER_HTTP_ADDR=:8080
|
||||
|
||||
@@ -10,16 +10,20 @@
|
||||
//
|
||||
// Environment variables:
|
||||
//
|
||||
// BROWSERLESS_URL Browserless base URL (default: http://localhost:3030)
|
||||
// BROWSERLESS_TOKEN Browserless API token (default: "")
|
||||
// BROWSERLESS_STRATEGY content | scrape | cdp (default: content)
|
||||
// BROWSERLESS_MAX_CONCURRENT Max simultaneous browser sessions (default: 5)
|
||||
// SCRAPER_WORKERS Chapter goroutine count (default: NumCPU)
|
||||
// SCRAPER_STATIC_ROOT Output directory (default: ./static/books)
|
||||
// SCRAPER_HTTP_ADDR HTTP listen address (default: :8080)
|
||||
// KOKORO_URL Kokoro-FastAPI base URL (default: "")
|
||||
// KOKORO_VOICE Default TTS voice (default: af_bella)
|
||||
// LOG_LEVEL debug | info | warn | error (default: info)
|
||||
// SCRAPER_WORKERS Chapter goroutine count (default: NumCPU)
|
||||
// SCRAPER_HTTP_ADDR HTTP listen address (default: :8080)
|
||||
// KOKORO_URL Kokoro-FastAPI base URL (default: "")
|
||||
// KOKORO_VOICE Default TTS voice (default: af_bella)
|
||||
// POCKETBASE_URL PocketBase API base URL (default: http://localhost:8090)
|
||||
// POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local)
|
||||
// POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123)
|
||||
// MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000)
|
||||
// MINIO_ACCESS_KEY MinIO access key (default: admin)
|
||||
// MINIO_SECRET_KEY MinIO secret key (default: changeme123)
|
||||
// MINIO_USE_SSL Use TLS for MinIO (default: false)
|
||||
// MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters)
|
||||
// MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio)
|
||||
// LOG_LEVEL debug | info | warn | error (default: info)
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +31,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -37,8 +42,9 @@ import (
|
||||
"github.com/libnovel/scraper/internal/browser"
|
||||
"github.com/libnovel/scraper/internal/novelfire"
|
||||
"github.com/libnovel/scraper/internal/orchestrator"
|
||||
"github.com/libnovel/scraper/internal/scraper/htmlutil"
|
||||
"github.com/libnovel/scraper/internal/server"
|
||||
"github.com/libnovel/scraper/internal/writer"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -67,30 +73,44 @@ func run(log *slog.Logger) error {
|
||||
|
||||
cmd := strings.ToLower(args[0])
|
||||
|
||||
browserCfg := browser.Config{
|
||||
BaseURL: envOr("BROWSERLESS_URL", "http://localhost:3030"),
|
||||
Token: envOr("BROWSERLESS_TOKEN", ""),
|
||||
}
|
||||
browserCfg.MaxConcurrent = 5
|
||||
if s := os.Getenv("BROWSERLESS_MAX_CONCURRENT"); s != "" {
|
||||
// All scraping uses direct HTTP — novelfire.net pages are server-rendered
|
||||
// and do not require a headless browser. A direct HTTP client is faster,
|
||||
// more reliable, and has no Browserless dependency.
|
||||
directCfg := browser.Config{MaxConcurrent: 5}
|
||||
if s := os.Getenv("SCRAPER_TIMEOUT"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
browserCfg.MaxConcurrent = n
|
||||
directCfg.Timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
if s := os.Getenv("BROWSERLESS_TIMEOUT"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
browserCfg.Timeout = time.Duration(n) * time.Second
|
||||
}
|
||||
directClient := browser.NewDirectHTTPClient(directCfg)
|
||||
|
||||
// ── Storage backends ────────────────────────────────────────────────────
|
||||
minioCfg := storage.MinioConfig{
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: strings.ToLower(os.Getenv("MINIO_USE_SSL")) == "true",
|
||||
PublicUseSSL: strings.ToLower(os.Getenv("MINIO_PUBLIC_USE_SSL")) != "false",
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
|
||||
}
|
||||
pbCfg := storage.PocketBaseConfig{
|
||||
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
||||
AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"),
|
||||
AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"),
|
||||
}
|
||||
|
||||
strategy := browser.Strategy(strings.ToLower(envOr("BROWSERLESS_STRATEGY", string(browser.StrategyDirect))))
|
||||
urlStrategy := browser.Strategy(strings.ToLower(envOr("BROWSERLESS_URL_STRATEGY", string(browser.StrategyContent))))
|
||||
bc := newBrowserClient(strategy, browserCfg)
|
||||
urlClient := newBrowserClient(urlStrategy, browserCfg)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
staticRoot := envOr("SCRAPER_STATIC_ROOT", "./static/books")
|
||||
w := writer.New(staticRoot)
|
||||
nf := novelfire.New(bc, log, urlClient, w)
|
||||
store, err := storage.NewHybridStore(ctx, pbCfg, minioCfg, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage init failed: %w", err)
|
||||
}
|
||||
|
||||
nf := novelfire.New(directClient, log, directClient, directClient, store)
|
||||
|
||||
workers := 0
|
||||
if s := os.Getenv("SCRAPER_WORKERS"); s != "" {
|
||||
@@ -104,13 +124,9 @@ func run(log *slog.Logger) error {
|
||||
}
|
||||
|
||||
oCfg := orchestrator.Config{
|
||||
Workers: workers,
|
||||
StaticRoot: staticRoot,
|
||||
Workers: workers,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
switch cmd {
|
||||
case "run":
|
||||
// Optional --url flag.
|
||||
@@ -118,13 +134,13 @@ func run(log *slog.Logger) error {
|
||||
oCfg.SingleBookURL = args[2]
|
||||
}
|
||||
log.Info("starting one-shot scrape",
|
||||
"strategy", strategy,
|
||||
"strategy", "direct",
|
||||
"workers", workers,
|
||||
"max_concurrent", browserCfg.MaxConcurrent,
|
||||
"static_root", oCfg.StaticRoot,
|
||||
"single_book", oCfg.SingleBookURL,
|
||||
"pocketbase_url", pbCfg.BaseURL,
|
||||
"pocketbase_email", pbCfg.AdminEmail,
|
||||
)
|
||||
o := orchestrator.New(oCfg, nf, log)
|
||||
o := orchestrator.New(oCfg, nf, log, store)
|
||||
return o.Run(ctx)
|
||||
|
||||
case "refresh":
|
||||
@@ -133,13 +149,12 @@ func run(log *slog.Logger) error {
|
||||
return fmt.Errorf("refresh command requires a book slug argument")
|
||||
}
|
||||
slug := args[1]
|
||||
w := writer.New(oCfg.StaticRoot)
|
||||
meta, ok, err := w.ReadMetadata(slug)
|
||||
meta, ok, err := store.ReadMetadata(ctx, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read metadata for %s: %w", slug, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("book %q not found in %s", slug, oCfg.StaticRoot)
|
||||
return fmt.Errorf("book %q not found in store", slug)
|
||||
}
|
||||
if meta.SourceURL == "" {
|
||||
return fmt.Errorf("book %q has no source_url in metadata", slug)
|
||||
@@ -148,41 +163,301 @@ func run(log *slog.Logger) error {
|
||||
log.Info("refreshing book from source_url",
|
||||
"slug", slug,
|
||||
"source_url", meta.SourceURL,
|
||||
"pocketbase_url", pbCfg.BaseURL,
|
||||
"pocketbase_email", pbCfg.AdminEmail,
|
||||
)
|
||||
o := orchestrator.New(oCfg, nf, log)
|
||||
o := orchestrator.New(oCfg, nf, log, store)
|
||||
return o.Run(ctx)
|
||||
|
||||
case "serve":
|
||||
addr := envOr("SCRAPER_HTTP_ADDR", ":8080")
|
||||
kokoroURL := envOr("KOKORO_URL", "")
|
||||
kokoroURL := envOr("KOKORO_URL", "https://kokoro.kalekber.cc")
|
||||
kokoroVoice := envOr("KOKORO_VOICE", "af_bella")
|
||||
log.Info("starting HTTP server",
|
||||
"addr", addr,
|
||||
"strategy", strategy,
|
||||
"strategy", "direct",
|
||||
"workers", workers,
|
||||
"max_concurrent", browserCfg.MaxConcurrent,
|
||||
"kokoro_url", kokoroURL,
|
||||
"kokoro_voice", kokoroVoice,
|
||||
"pocketbase_url", pbCfg.BaseURL,
|
||||
"pocketbase_email", pbCfg.AdminEmail,
|
||||
)
|
||||
srv := server.New(addr, oCfg, nf, log, kokoroURL, kokoroVoice)
|
||||
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
|
||||
return srv.ListenAndServe(ctx)
|
||||
|
||||
case "save-browse":
|
||||
return runSaveBrowse(ctx, args[1:], store, log)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q; use 'run' or 'serve'", cmd)
|
||||
return fmt.Errorf("unknown command %q; use 'run', 'refresh', 'serve', or 'save-browse'", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func newBrowserClient(strategy browser.Strategy, cfg browser.Config) browser.BrowserClient {
|
||||
switch strategy {
|
||||
case browser.StrategyScrape:
|
||||
return browser.NewScrapeClient(cfg)
|
||||
case browser.StrategyCDP:
|
||||
return browser.NewCDPClient(cfg)
|
||||
case browser.StrategyDirect:
|
||||
return browser.NewDirectHTTPClient(cfg)
|
||||
default:
|
||||
return browser.NewContentClient(cfg)
|
||||
// runSaveBrowse implements the `save-browse` subcommand.
|
||||
// It iterates over browse pages on novelfire.net, captures each using
|
||||
// SingleFile CLI (connected to the existing Browserless instance), and
|
||||
// stores the resulting self-contained HTML in the MinIO browse bucket.
|
||||
// After storing each page it parses the HTML, upserts ranking records in
|
||||
// PocketBase, and fires background goroutines to download cover images.
|
||||
//
|
||||
// Flags (all optional):
|
||||
//
|
||||
// --genre <value> genre slug (default: all)
|
||||
// --sort <value> sort order (default: popular)
|
||||
// --status <value> status (default: all)
|
||||
// --type <value> novel type (default: all-novel)
|
||||
// --max-pages <n> max pages (default: 5)
|
||||
func runSaveBrowse(ctx context.Context, args []string, store storage.Store, log *slog.Logger) error {
|
||||
// Parse flags manually to avoid importing flag package.
|
||||
genre := "all"
|
||||
sortBy := "popular"
|
||||
status := "all"
|
||||
novelType := "all-novel"
|
||||
maxPages := 5
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--genre":
|
||||
if i+1 < len(args) {
|
||||
genre = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--sort":
|
||||
if i+1 < len(args) {
|
||||
sortBy = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--status":
|
||||
if i+1 < len(args) {
|
||||
status = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--type":
|
||||
if i+1 < len(args) {
|
||||
novelType = args[i+1]
|
||||
i++
|
||||
}
|
||||
case "--max-pages":
|
||||
if i+1 < len(args) {
|
||||
if n, err := strconv.Atoi(args[i+1]); err == nil && n > 0 {
|
||||
maxPages = n
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
singleFilePath := envOr("SINGLEFILE_PATH", "single-file")
|
||||
browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030")
|
||||
// SingleFile expects a WebSocket CDP endpoint.
|
||||
// Browserless exposes /chromium at the WS root.
|
||||
wsEndpoint := strings.Replace(browserlessURL, "http://", "ws://", 1)
|
||||
wsEndpoint = strings.Replace(wsEndpoint, "https://", "wss://", 1)
|
||||
|
||||
log.Info("save-browse: starting",
|
||||
"genre", genre, "sort", sortBy, "status", status,
|
||||
"type", novelType, "max_pages", maxPages,
|
||||
"singlefile", singleFilePath,
|
||||
"browserless_ws", wsEndpoint,
|
||||
)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "libnovel-browse-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("save-browse: create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
const novelFireBase = "https://novelfire.net"
|
||||
const novelFireDomain = "novelfire.net"
|
||||
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
pageURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d",
|
||||
novelFireBase, genre, sortBy, status, novelType, page)
|
||||
|
||||
// Use the new domain-based key layout: {domain}/html/page-{n}.html
|
||||
key := store.BrowseHTMLKey(novelFireDomain, page)
|
||||
|
||||
outFile := fmt.Sprintf("%s/page-%d.html", tmpDir, page)
|
||||
|
||||
log.Info("save-browse: capturing page", "page", page, "url", pageURL)
|
||||
|
||||
//nolint:gosec // singleFilePath and pageURL are config/URL values, not user input.
|
||||
cmd := exec.CommandContext(ctx, singleFilePath,
|
||||
pageURL,
|
||||
"--browser-server="+wsEndpoint,
|
||||
"--output="+outFile,
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if runErr := cmd.Run(); runErr != nil {
|
||||
log.Warn("save-browse: SingleFile failed, skipping page",
|
||||
"page", page, "err", runErr)
|
||||
continue
|
||||
}
|
||||
|
||||
htmlBytes, readErr := os.ReadFile(outFile)
|
||||
if readErr != nil {
|
||||
log.Warn("save-browse: failed to read output file",
|
||||
"page", page, "file", outFile, "err", readErr)
|
||||
continue
|
||||
}
|
||||
|
||||
if putErr := store.SaveBrowsePage(ctx, key, string(htmlBytes)); putErr != nil {
|
||||
log.Warn("save-browse: failed to store snapshot in MinIO",
|
||||
"page", page, "key", key, "err", putErr)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info("save-browse: snapshot stored", "page", page, "key", key,
|
||||
"bytes", len(htmlBytes))
|
||||
|
||||
// Parse the stored HTML and populate the ranking collection.
|
||||
novels := parseSaveBrowseListings(htmlBytes, novelFireBase)
|
||||
for i, novel := range novels {
|
||||
rank := i + 1
|
||||
coverKey := store.BrowseCoverKey(novelFireDomain, novel.slug)
|
||||
|
||||
item := storage.RankingItem{
|
||||
Rank: rank,
|
||||
Slug: novel.slug,
|
||||
Title: novel.title,
|
||||
Cover: coverKey,
|
||||
SourceURL: novel.url,
|
||||
}
|
||||
if werr := store.WriteRankingItem(ctx, item); werr != nil {
|
||||
log.Warn("save-browse: WriteRankingItem failed",
|
||||
"slug", novel.slug, "err", werr)
|
||||
}
|
||||
|
||||
// Download cover image in the background (best-effort).
|
||||
if novel.coverURL != "" {
|
||||
go storage.DownloadAndStoreCover(store, log, coverKey, novel.coverURL)
|
||||
}
|
||||
}
|
||||
if len(novels) > 0 {
|
||||
log.Info("save-browse: ranking populated", "page", page, "count", len(novels))
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("save-browse: done")
|
||||
return nil
|
||||
}
|
||||
|
||||
// novelListingCLI is a minimal novel listing used within the CLI command.
|
||||
type novelListingCLI struct {
|
||||
slug string
|
||||
title string
|
||||
url string
|
||||
coverURL string
|
||||
}
|
||||
|
||||
// parseSaveBrowseListings extracts novel listings from raw HTML bytes.
|
||||
// It reuses the same parsing logic as the server's parseBrowsePage but
|
||||
// operates on []byte to avoid importing the server package.
|
||||
func parseSaveBrowseListings(htmlBytes []byte, novelFireBase string) []novelListingCLI {
|
||||
type listing = novelListingCLI
|
||||
|
||||
// Minimal tokeniser-based walk to find <li class="novel-item"> blocks.
|
||||
// We use the golang.org/x/net/html parser via a local import.
|
||||
// Because main.go already imports golang.org/x/net/html indirectly through
|
||||
// the server package build, we do a simple line-scan here instead to keep
|
||||
// the dependency surface small.
|
||||
//
|
||||
// Strategy: scan for href="/book/{slug}", img data-src/src, h4.novel-title text.
|
||||
var novels []listing
|
||||
|
||||
lines := strings.Split(string(htmlBytes), "\n")
|
||||
var cur listing
|
||||
inNovelItem := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Detect start of a novel-item list element.
|
||||
if strings.Contains(trimmed, `class="novel-item"`) || strings.Contains(trimmed, "novel-item") && strings.HasPrefix(trimmed, "<li") {
|
||||
inNovelItem = true
|
||||
cur = listing{}
|
||||
}
|
||||
|
||||
if !inNovelItem {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect end of list element.
|
||||
if trimmed == "</li>" && cur.slug != "" {
|
||||
novels = append(novels, cur)
|
||||
inNovelItem = false
|
||||
cur = listing{}
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract slug from href="/book/{slug}".
|
||||
if cur.slug == "" {
|
||||
if idx := strings.Index(trimmed, `href="/book/`); idx >= 0 {
|
||||
rest := trimmed[idx+len(`href="/book/`):]
|
||||
if end := strings.IndexAny(rest, `"/ `); end > 0 {
|
||||
cur.slug = rest[:end]
|
||||
cur.url = novelFireBase + "/book/" + cur.slug
|
||||
} else if end := strings.Index(rest, `"`); end > 0 {
|
||||
cur.slug = strings.TrimSuffix(rest[:end], "/")
|
||||
cur.url = novelFireBase + "/book/" + cur.slug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cover URL from data-src or src on img tags.
|
||||
if cur.coverURL == "" && strings.Contains(trimmed, "<img") {
|
||||
if src := extractAttr(trimmed, "data-src"); src != "" {
|
||||
cur.coverURL = htmlutil.ResolveURL(novelFireBase, src)
|
||||
} else if src := extractAttr(trimmed, "src"); src != "" && !strings.Contains(src, "data:") {
|
||||
cur.coverURL = htmlutil.ResolveURL(novelFireBase, src)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract title from novel-title element.
|
||||
if cur.title == "" && strings.Contains(trimmed, "novel-title") {
|
||||
// Try to grab inner text: <h4 class="novel-title">Title Here</h4>
|
||||
if start := strings.Index(trimmed, ">"); start >= 0 {
|
||||
rest := trimmed[start+1:]
|
||||
if end := strings.Index(rest, "<"); end > 0 {
|
||||
title := strings.TrimSpace(rest[:end])
|
||||
if title != "" {
|
||||
cur.title = title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any open item that wasn't closed by </li> (e.g. last item in file).
|
||||
if inNovelItem && cur.slug != "" {
|
||||
novels = append(novels, cur)
|
||||
}
|
||||
|
||||
return novels
|
||||
}
|
||||
|
||||
// extractAttr extracts an HTML attribute value from a raw tag string.
|
||||
// e.g. extractAttr(`<img data-src="foo.jpg">`, "data-src") → "foo.jpg"
|
||||
func extractAttr(tag, attr string) string {
|
||||
needle := attr + `="`
|
||||
idx := strings.Index(tag, needle)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := tag[idx+len(needle):]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
@@ -199,19 +474,31 @@ Commands:
|
||||
run [--url <book-url>] One-shot: scrape full catalogue, or a single book
|
||||
refresh <slug> Re-scrape a book from its saved source_url
|
||||
serve Start HTTP server (POST /scrape, POST /scrape/book)
|
||||
save-browse Capture browse pages via SingleFile → MinIO
|
||||
--genre <slug> genre filter (default: all)
|
||||
--sort <value> sort order (default: popular)
|
||||
--status <value> status filter (default: all)
|
||||
--type <value> novel type (default: all-novel)
|
||||
--max-pages <n> pages to capture (default: 5)
|
||||
|
||||
Environment variables:
|
||||
BROWSERLESS_URL Browserless base URL (default: http://localhost:3030)
|
||||
BROWSERLESS_TOKEN API token (default: "")
|
||||
BROWSERLESS_STRATEGY content|scrape|cdp|direct (default: direct)
|
||||
BROWSERLESS_URL_STRATEGY Strategy for URL retrieval (default: content)
|
||||
BROWSERLESS_MAX_CONCURRENT Max simultaneous sessions (default: 5)
|
||||
BROWSERLESS_TIMEOUT HTTP request timeout sec (default: 90)
|
||||
SCRAPER_WORKERS Chapter goroutines (default: NumCPU = %d)
|
||||
SCRAPER_STATIC_ROOT Output directory (default: ./static/books)
|
||||
SCRAPER_HTTP_ADDR HTTP listen address (default: :8080)
|
||||
SCRAPER_TIMEOUT HTTP request timeout sec (default: 90)
|
||||
KOKORO_URL Kokoro-FastAPI base URL (default: "", TTS disabled)
|
||||
KOKORO_VOICE Default TTS voice (default: af_bella)
|
||||
POCKETBASE_URL PocketBase base URL (default: http://localhost:8090)
|
||||
POCKETBASE_ADMIN_EMAIL PocketBase admin email (default: admin@libnovel.local)
|
||||
POCKETBASE_ADMIN_PASSWORD PocketBase admin password (default: changeme123)
|
||||
MINIO_ENDPOINT MinIO endpoint host:port (default: localhost:9000)
|
||||
MINIO_ACCESS_KEY MinIO access key (default: admin)
|
||||
MINIO_SECRET_KEY MinIO secret key (default: changeme123)
|
||||
MINIO_USE_SSL MinIO TLS (default: false)
|
||||
MINIO_BUCKET_CHAPTERS Chapter objects bucket (default: libnovel-chapters)
|
||||
MINIO_BUCKET_AUDIO Audio objects bucket (default: libnovel-audio)
|
||||
MINIO_BUCKET_BROWSE Browse snapshots bucket (default: libnovel-browse)
|
||||
BROWSERLESS_URL Browserless WS endpoint (default: http://localhost:3030)
|
||||
SINGLEFILE_PATH Path to single-file CLI (default: single-file)
|
||||
LOG_LEVEL debug|info|warn|error (default: info)
|
||||
`, runtime.NumCPU())
|
||||
}
|
||||
|
||||
@@ -2,17 +2,36 @@ module github.com/libnovel/scraper
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
golang.org/x/net v0.51.0
|
||||
honnef.co/go/tools v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
)
|
||||
|
||||
tool honnef.co/go/tools/cmd/staticcheck
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
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=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
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=
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// cdpClient implements BrowserClient using the CDP WebSocket endpoint.
|
||||
type cdpClient struct {
|
||||
cfg Config
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
// NewCDPClient returns a BrowserClient that uses CDP WebSocket sessions.
|
||||
func NewCDPClient(cfg Config) BrowserClient {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 60 * time.Second
|
||||
}
|
||||
return &cdpClient{cfg: cfg, sem: makeSem(cfg.MaxConcurrent)}
|
||||
}
|
||||
|
||||
func (c *cdpClient) Strategy() Strategy { return StrategyCDP }
|
||||
|
||||
func (c *cdpClient) GetContent(_ context.Context, _ ContentRequest) (string, error) {
|
||||
return "", fmt.Errorf("CDP client does not support /content; use NewContentClient")
|
||||
}
|
||||
|
||||
func (c *cdpClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeResponse, error) {
|
||||
return ScrapeResponse{}, fmt.Errorf("CDP client does not support /scrape; use NewScrapeClient")
|
||||
}
|
||||
|
||||
// CDPSession opens a WebSocket to the Browserless /devtools/browser endpoint,
|
||||
// navigates to pageURL, and invokes fn with a live CDPConn.
|
||||
func (c *cdpClient) CDPSession(ctx context.Context, pageURL string, fn CDPSessionFunc) error {
|
||||
if err := acquire(ctx, c.sem); err != nil {
|
||||
return fmt.Errorf("cdp: semaphore: %w", err)
|
||||
}
|
||||
defer release(c.sem)
|
||||
|
||||
// Build WebSocket URL: ws://host:port/devtools/browser?token=...&url=...
|
||||
wsURL := strings.Replace(c.cfg.BaseURL, "http://", "ws://", 1)
|
||||
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
|
||||
wsURL += "/devtools/browser"
|
||||
sep := "?"
|
||||
if c.cfg.Token != "" {
|
||||
wsURL += sep + "token=" + c.cfg.Token
|
||||
sep = "&"
|
||||
}
|
||||
wsURL += sep + "url=" + pageURL
|
||||
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 15 * time.Second,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
|
||||
conn, _, err := dialer.DialContext(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cdp: dial %s: %w", wsURL, err)
|
||||
}
|
||||
|
||||
cdp := &cdpConn{ws: conn}
|
||||
defer cdp.Close()
|
||||
|
||||
return fn(ctx, cdp)
|
||||
}
|
||||
|
||||
// ─── cdpConn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type cdpConn struct {
|
||||
ws *websocket.Conn
|
||||
counter atomic.Int64
|
||||
}
|
||||
|
||||
type cdpRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type cdpResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Result map[string]any `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *cdpConn) Send(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
|
||||
id := c.counter.Add(1)
|
||||
|
||||
req := cdpRequest{ID: id, Method: method, Params: params}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp send: marshal: %w", err)
|
||||
}
|
||||
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = c.ws.SetWriteDeadline(dl)
|
||||
}
|
||||
if err := c.ws.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return nil, fmt.Errorf("cdp send: write: %w", err)
|
||||
}
|
||||
|
||||
// Read messages until we find the response matching our id.
|
||||
for {
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = c.ws.SetReadDeadline(dl)
|
||||
}
|
||||
_, msg, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp send: read: %w", err)
|
||||
}
|
||||
var resp cdpResponse
|
||||
if err := json.Unmarshal(msg, &resp); err != nil {
|
||||
continue // skip non-JSON frames (events etc.)
|
||||
}
|
||||
if resp.ID != id {
|
||||
continue // event or different command reply
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("cdp error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
return resp.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cdpConn) Close() error {
|
||||
return c.ws.Close()
|
||||
}
|
||||
@@ -55,6 +55,8 @@ func release(sem chan struct{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /content client ──────────────────────────────────────────────────────────
|
||||
|
||||
// contentClient implements BrowserClient using the /content endpoint.
|
||||
type contentClient struct {
|
||||
cfg Config
|
||||
@@ -121,75 +123,5 @@ func (c *contentClient) ScrapePage(_ context.Context, _ ScrapeRequest) (ScrapeRe
|
||||
}
|
||||
|
||||
func (c *contentClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error {
|
||||
return fmt.Errorf("content client does not support CDP; use NewCDPClient")
|
||||
}
|
||||
|
||||
// ─── /scrape client ───────────────────────────────────────────────────────────
|
||||
|
||||
type scrapeClient struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
// NewScrapeClient returns a BrowserClient that uses POST /scrape.
|
||||
func NewScrapeClient(cfg Config) BrowserClient {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 90 * time.Second
|
||||
}
|
||||
return &scrapeClient{
|
||||
cfg: cfg,
|
||||
http: &http.Client{Timeout: cfg.Timeout},
|
||||
sem: makeSem(cfg.MaxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *scrapeClient) Strategy() Strategy { return StrategyScrape }
|
||||
|
||||
func (c *scrapeClient) GetContent(_ context.Context, _ ContentRequest) (string, error) {
|
||||
return "", fmt.Errorf("scrape client does not support /content; use NewContentClient")
|
||||
}
|
||||
|
||||
func (c *scrapeClient) ScrapePage(ctx context.Context, req ScrapeRequest) (ScrapeResponse, error) {
|
||||
if err := acquire(ctx, c.sem); err != nil {
|
||||
return ScrapeResponse{}, fmt.Errorf("scrape: semaphore: %w", err)
|
||||
}
|
||||
defer release(c.sem)
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return ScrapeResponse{}, fmt.Errorf("scrape: marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := c.cfg.BaseURL + "/scrape"
|
||||
if c.cfg.Token != "" {
|
||||
url += "?token=" + c.cfg.Token
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return ScrapeResponse{}, fmt.Errorf("scrape: build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return ScrapeResponse{}, fmt.Errorf("scrape: do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return ScrapeResponse{}, fmt.Errorf("scrape: unexpected status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var result ScrapeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return ScrapeResponse{}, fmt.Errorf("scrape: decode response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *scrapeClient) CDPSession(_ context.Context, _ string, _ CDPSessionFunc) error {
|
||||
return fmt.Errorf("scrape client does not support CDP; use NewCDPClient")
|
||||
return fmt.Errorf("content client does not support CDP")
|
||||
}
|
||||
818
scraper/internal/e2e/e2e_test.go
Normal file
818
scraper/internal/e2e/e2e_test.go
Normal file
@@ -0,0 +1,818 @@
|
||||
//go:build integration
|
||||
|
||||
// End-to-end integration test for libnovel.
|
||||
//
|
||||
// Scenario (executed in order):
|
||||
// 1. Health-check all Docker services (PocketBase, MinIO, Browserless, scraper).
|
||||
// 2. Register a test user in the app_users PocketBase collection.
|
||||
// 3. Scrape the popular-ranking page 1 and capture the first book.
|
||||
// 4. Scrape full metadata for that book and persist it; verify in PocketBase.
|
||||
// 5. Scrape chapters 1–3 and persist them; verify in MinIO + PocketBase.
|
||||
// 6. Generate TTS audio for the first 100 chars of each chapter via the scraper
|
||||
// HTTP API; verify MinIO object + PocketBase audio_cache entry.
|
||||
// 7. Fetch presigned URLs for each chapter's markdown and audio; verify HTTP 200.
|
||||
//
|
||||
// Prerequisites (all must be running):
|
||||
//
|
||||
// docker-compose up -d minio pocketbase browserless scraper
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// BROWSERLESS_URL=http://localhost:3030 \
|
||||
// MINIO_ENDPOINT=localhost:9000 \
|
||||
// POCKETBASE_URL=http://localhost:8090 \
|
||||
// SCRAPER_URL=http://localhost:8080 \
|
||||
// go test -v -tags integration -timeout 900s \
|
||||
// github.com/libnovel/scraper/internal/e2e
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/browser"
|
||||
"github.com/libnovel/scraper/internal/novelfire"
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// ─── env helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ─── fixture ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type e2eFixture struct {
|
||||
sc *novelfire.Scraper
|
||||
hs *storage.HybridStore
|
||||
scraperURL string // base URL of the running scraper HTTP server
|
||||
pbBaseURL string
|
||||
pbEmail string
|
||||
pbPassword string
|
||||
}
|
||||
|
||||
func newE2EFixture(t *testing.T) *e2eFixture {
|
||||
t.Helper()
|
||||
|
||||
browserlessURL := envOr("BROWSERLESS_URL", "")
|
||||
if browserlessURL == "" {
|
||||
t.Skip("BROWSERLESS_URL not set — skipping e2e test")
|
||||
}
|
||||
if os.Getenv("MINIO_ENDPOINT") == "" {
|
||||
t.Skip("MINIO_ENDPOINT not set — skipping e2e test")
|
||||
}
|
||||
if os.Getenv("POCKETBASE_URL") == "" {
|
||||
t.Skip("POCKETBASE_URL not set — skipping e2e test")
|
||||
}
|
||||
scraperURL := envOr("SCRAPER_URL", "http://localhost:8080")
|
||||
|
||||
pbBaseURL := envOr("POCKETBASE_URL", "http://localhost:8090")
|
||||
pbEmail := envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local")
|
||||
pbPassword := envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123")
|
||||
|
||||
pbCfg := storage.PocketBaseConfig{
|
||||
BaseURL: pbBaseURL,
|
||||
AdminEmail: pbEmail,
|
||||
AdminPassword: pbPassword,
|
||||
}
|
||||
minioCfg := storage.MinioConfig{
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: envOr("MINIO_USE_SSL", "false") == "true",
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
|
||||
// directClient: plain HTTP GET — used for chapter text, metadata, and ranking
|
||||
// (novelfire.net serves these pages server-side; no JS rendering needed).
|
||||
directClient := browser.NewDirectHTTPClient(browser.Config{
|
||||
Timeout: 60 * time.Second,
|
||||
MaxConcurrent: 2,
|
||||
})
|
||||
// urlClient: Browserless content strategy — used only for chapter-list
|
||||
// pagination pages which require JS rendering to populate the list.
|
||||
urlClient := browser.NewContentClient(browser.Config{
|
||||
BaseURL: browserlessURL,
|
||||
Token: os.Getenv("BROWSERLESS_TOKEN"),
|
||||
Timeout: 120 * time.Second,
|
||||
MaxConcurrent: 2,
|
||||
})
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
sc := novelfire.New(directClient, log, urlClient, directClient, nil)
|
||||
|
||||
return &e2eFixture{
|
||||
sc: sc,
|
||||
hs: hs,
|
||||
scraperURL: scraperURL,
|
||||
pbBaseURL: pbBaseURL,
|
||||
pbEmail: pbEmail,
|
||||
pbPassword: pbPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── The single end-to-end test ───────────────────────────────────────────────
|
||||
|
||||
// TestE2E_FullScenario executes the complete end-to-end scenario in order.
|
||||
func TestE2E_FullScenario(t *testing.T) {
|
||||
f := newE2EFixture(t)
|
||||
|
||||
// ── Step 1: Health-check services ────────────────────────────────────────
|
||||
t.Run("step1_health_checks", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// PocketBase health
|
||||
pbHealth := f.pbBaseURL + "/api/health"
|
||||
checkHTTP(t, ctx, pbHealth, "PocketBase")
|
||||
|
||||
// MinIO health — the MinIO console liveness endpoint
|
||||
minioEndpoint := envOr("MINIO_ENDPOINT", "localhost:9000")
|
||||
scheme := "http"
|
||||
if envOr("MINIO_USE_SSL", "false") == "true" {
|
||||
scheme = "https"
|
||||
}
|
||||
minioHealth := fmt.Sprintf("%s://%s/minio/health/live", scheme, minioEndpoint)
|
||||
checkHTTP(t, ctx, minioHealth, "MinIO")
|
||||
|
||||
// Browserless health — /pressure is the liveness endpoint
|
||||
browserlessURL := envOr("BROWSERLESS_URL", "http://localhost:3030")
|
||||
blHealth := browserlessURL + "/pressure"
|
||||
checkHTTP(t, ctx, blHealth, "Browserless")
|
||||
|
||||
// Scraper server health — wait up to 10 s for it to be ready
|
||||
scraperHealth := f.scraperURL + "/health"
|
||||
waitForHTTP(t, ctx, scraperHealth, "scraper server", 10*time.Second)
|
||||
})
|
||||
|
||||
// ── Step 2: Register test user ────────────────────────────────────────────
|
||||
var testUsername string
|
||||
t.Run("step2_register_user", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
testUsername = fmt.Sprintf("e2euser-%d", time.Now().UnixMilli()%100000)
|
||||
passwordHash := "pbkdf2:sha256:dummy-hash-for-test"
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanCancel()
|
||||
deleteAppUser(t, f, cleanCtx, testUsername)
|
||||
})
|
||||
|
||||
if err := createAppUser(ctx, f, testUsername, passwordHash, "reader"); err != nil {
|
||||
t.Fatalf("createAppUser: %v", err)
|
||||
}
|
||||
t.Logf("created user %q", testUsername)
|
||||
|
||||
// Verify the user exists in PocketBase.
|
||||
rec, err := getAppUserByUsername(ctx, f, testUsername)
|
||||
if err != nil {
|
||||
t.Fatalf("getAppUserByUsername: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("user not found in app_users after creation")
|
||||
}
|
||||
if rec["username"] != testUsername {
|
||||
t.Errorf("username = %q, want %q", rec["username"], testUsername)
|
||||
}
|
||||
t.Logf("user verified in PocketBase: id=%v username=%v role=%v", rec["id"], rec["username"], rec["role"])
|
||||
})
|
||||
|
||||
// ── Step 3: Scrape ranking page 1, capture first book ────────────────────
|
||||
var firstBook scraper.BookMeta
|
||||
t.Run("step3_scrape_ranking", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, errs := f.sc.ScrapeRanking(ctx, 1) // maxPages=1 → only page 1
|
||||
|
||||
select {
|
||||
case meta, ok := <-entries:
|
||||
if !ok {
|
||||
t.Fatal("ranking channel closed without any entry")
|
||||
}
|
||||
firstBook = meta
|
||||
case err := <-errs:
|
||||
t.Fatalf("ScrapeRanking error: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal("ScrapeRanking timed out waiting for first entry")
|
||||
}
|
||||
|
||||
// Drain remaining entries and errors.
|
||||
for range entries {
|
||||
}
|
||||
for range errs {
|
||||
}
|
||||
|
||||
if firstBook.Slug == "" {
|
||||
t.Fatal("first book has empty slug")
|
||||
}
|
||||
if firstBook.Title == "" {
|
||||
t.Fatal("first book has empty title")
|
||||
}
|
||||
if firstBook.SourceURL == "" {
|
||||
t.Fatal("first book has empty SourceURL")
|
||||
}
|
||||
t.Logf("first ranked book: slug=%q title=%q rank=%d url=%s",
|
||||
firstBook.Slug, firstBook.Title, firstBook.Ranking, firstBook.SourceURL)
|
||||
})
|
||||
|
||||
if firstBook.Slug == "" || firstBook.SourceURL == "" {
|
||||
t.Fatal("cannot continue: step3 did not produce a valid first book")
|
||||
}
|
||||
|
||||
// Use a unique slug for the test to avoid colliding with real scraped data.
|
||||
testSlug := fmt.Sprintf("%s-e2e-%d", firstBook.Slug, time.Now().UnixMilli()%100000)
|
||||
t.Logf("using test slug: %q", testSlug)
|
||||
|
||||
// Register cleanup for all data written by subsequent steps.
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cleanCancel()
|
||||
cleanupTestData(t, f, cleanCtx, testSlug)
|
||||
})
|
||||
|
||||
// ── Step 4: Scrape book metadata and persist ──────────────────────────────
|
||||
var fullMeta scraper.BookMeta
|
||||
t.Run("step4_scrape_metadata", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
meta, err := f.sc.ScrapeMetadata(ctx, firstBook.SourceURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrapeMetadata: %v", err)
|
||||
}
|
||||
t.Logf("scraped metadata: title=%q author=%q totalChapters=%d",
|
||||
meta.Title, meta.Author, meta.TotalChapters)
|
||||
|
||||
// Override slug so data lands under our test slug.
|
||||
meta.Slug = testSlug
|
||||
fullMeta = meta
|
||||
|
||||
storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer storeCancel()
|
||||
|
||||
if err := f.hs.WriteMetadata(storeCtx, meta); err != nil {
|
||||
t.Fatalf("WriteMetadata: %v", err)
|
||||
}
|
||||
|
||||
// Verify in PocketBase.
|
||||
got, found, err := f.hs.ReadMetadata(storeCtx, testSlug)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMetadata: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("book not found in PocketBase after WriteMetadata")
|
||||
}
|
||||
if got.Title == "" {
|
||||
t.Error("book title is empty after round-trip")
|
||||
}
|
||||
if got.Author == "" {
|
||||
t.Logf("WARNING: book author is empty after round-trip (site may not expose author for this book)")
|
||||
}
|
||||
t.Logf("PocketBase verified: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters)
|
||||
})
|
||||
|
||||
if fullMeta.SourceURL == "" {
|
||||
fullMeta.SourceURL = firstBook.SourceURL
|
||||
}
|
||||
|
||||
// ── Step 5: Scrape first 3 chapters and persist ───────────────────────────
|
||||
var chapterRefs []scraper.ChapterRef
|
||||
t.Run("step5_scrape_chapters", func(t *testing.T) {
|
||||
// Fetch only page 1 of the chapter list from
|
||||
// https://novelfire.net/book/{slug}/chapters?page=1
|
||||
// to avoid paginating through hundreds of pages for popular books.
|
||||
listCtx, listCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer listCancel()
|
||||
|
||||
chaptersPageURL := firstBook.SourceURL + "/chapters?page=1"
|
||||
refs, err := scrapeChapterListPage1(listCtx, f, chaptersPageURL)
|
||||
if err != nil {
|
||||
t.Fatalf("scrapeChapterListPage1: %v", err)
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("chapter list page 1 returned no chapters")
|
||||
}
|
||||
t.Logf("chapter list page 1: %d chapters found", len(refs))
|
||||
|
||||
// Take the first 3 (or fewer if page 1 has < 3 chapters).
|
||||
n := 3
|
||||
if len(refs) < n {
|
||||
n = len(refs)
|
||||
}
|
||||
chapterRefs = refs[:n]
|
||||
t.Logf("will scrape first %d chapters: %v", n, chapterNumbers(chapterRefs))
|
||||
|
||||
for _, ref := range chapterRefs {
|
||||
ref := ref
|
||||
t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) {
|
||||
scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer scrapeCancel()
|
||||
|
||||
ch, err := f.sc.ScrapeChapterText(scrapeCtx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err)
|
||||
}
|
||||
t.Logf("scraped chapter %d: %d bytes", ref.Number, len(ch.Text))
|
||||
if len(ch.Text) < 50 {
|
||||
t.Errorf("chapter %d text too short (%d bytes)", ref.Number, len(ch.Text))
|
||||
}
|
||||
|
||||
// Override ref slug with our test slug.
|
||||
ch.Ref.Number = ref.Number
|
||||
ch.Ref.Title = ref.Title
|
||||
|
||||
storeCtx, storeCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer storeCancel()
|
||||
|
||||
if err := f.hs.WriteChapter(storeCtx, testSlug, ch); err != nil {
|
||||
t.Fatalf("WriteChapter(%d): %v", ref.Number, err)
|
||||
}
|
||||
|
||||
// Verify in MinIO via ReadChapter.
|
||||
got, err := f.hs.ReadChapter(storeCtx, testSlug, ref.Number)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadChapter(%d): %v", ref.Number, err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("chapter %d: ReadChapter returned empty content", ref.Number)
|
||||
}
|
||||
if !strings.HasPrefix(got, "# ") {
|
||||
t.Errorf("chapter %d: stored content missing markdown header (got %q)", ref.Number, got[:min(len(got), 80)])
|
||||
}
|
||||
|
||||
// Verify PocketBase chapters_idx entry.
|
||||
idxCtx, idxCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer idxCancel()
|
||||
count := f.hs.CountChapters(idxCtx, testSlug)
|
||||
if count == 0 {
|
||||
t.Errorf("chapter %d: chapters_idx count = 0 after WriteChapter", ref.Number)
|
||||
}
|
||||
t.Logf("chapter %d stored; chapters_idx count=%d", ref.Number, count)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if len(chapterRefs) == 0 {
|
||||
t.Fatal("cannot continue: step5 produced no chapter refs")
|
||||
}
|
||||
|
||||
// ── Step 6: Generate TTS audio via scraper HTTP API ───────────────────────
|
||||
t.Run("step6_tts_audio", func(t *testing.T) {
|
||||
if os.Getenv("SCRAPER_URL") == "" {
|
||||
t.Skip("SCRAPER_URL not set — skipping TTS step")
|
||||
}
|
||||
|
||||
voice := envOr("KOKORO_VOICE", "af_bella")
|
||||
|
||||
for _, ref := range chapterRefs {
|
||||
ref := ref
|
||||
t.Run(fmt.Sprintf("audio-chapter-%d", ref.Number), func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
audioURL := fmt.Sprintf("%s/api/audio/%s/%d", f.scraperURL, testSlug, ref.Number)
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"voice": voice,
|
||||
"speed": 1.0,
|
||||
"max_chars": 200,
|
||||
})
|
||||
|
||||
audioReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, audioURL, bytes.NewReader(body))
|
||||
audioReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(audioReq)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", audioURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("audio generation status=%d body=%s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var audioResp struct {
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&audioResp); err != nil {
|
||||
t.Fatalf("decode audio response: %v", err)
|
||||
}
|
||||
if audioResp.URL == "" {
|
||||
t.Error("audio response has empty url field")
|
||||
}
|
||||
if audioResp.Filename == "" {
|
||||
t.Error("audio response has empty filename field")
|
||||
}
|
||||
t.Logf("chapter %d audio: url=%s filename=%s", ref.Number, audioResp.URL, audioResp.Filename)
|
||||
|
||||
// Verify audio_cache entry exists in PocketBase.
|
||||
pbCtx, pbCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer pbCancel()
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s/1.00", testSlug, ref.Number, voice)
|
||||
filename, found := f.hs.GetAudioCache(pbCtx, cacheKey)
|
||||
if !found {
|
||||
t.Errorf("audio_cache entry not found for key=%q", cacheKey)
|
||||
} else {
|
||||
t.Logf("audio_cache[%q] = %q", cacheKey, filename)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Step 7: Presigned URLs ────────────────────────────────────────────────
|
||||
t.Run("step7_presigned_urls", func(t *testing.T) {
|
||||
if os.Getenv("SCRAPER_URL") == "" {
|
||||
t.Skip("SCRAPER_URL not set — skipping presign step")
|
||||
}
|
||||
|
||||
// Give the background MinIO upload goroutines (launched by handleAudioGenerate)
|
||||
// a moment to complete before we attempt to access the presigned URLs.
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
voice := envOr("KOKORO_VOICE", "af_bella")
|
||||
|
||||
for _, ref := range chapterRefs {
|
||||
ref := ref
|
||||
t.Run(fmt.Sprintf("presign-chapter-%d", ref.Number), func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Chapter markdown presign.
|
||||
chPresignURL := fmt.Sprintf("%s/api/presign/chapter/%s/%d",
|
||||
f.scraperURL, testSlug, ref.Number)
|
||||
chPresigned := fetchPresignedURL(t, ctx, chPresignURL, "chapter presign")
|
||||
if chPresigned != "" {
|
||||
assertURLAccessible(t, ctx, chPresigned, fmt.Sprintf("chapter %d presigned URL", ref.Number))
|
||||
}
|
||||
|
||||
// Audio presign — poll with retries to allow background MinIO upload to finish.
|
||||
auPresignURL := fmt.Sprintf("%s/api/presign/audio/%s/%d?voice=%s&speed=1.0",
|
||||
f.scraperURL, testSlug, ref.Number, voice)
|
||||
auPresigned := fetchPresignedURL(t, ctx, auPresignURL, "audio presign")
|
||||
if auPresigned != "" {
|
||||
assertURLAccessibleWithRetry(t, ctx, auPresigned, fmt.Sprintf("chapter %d audio presigned URL", ref.Number), 6, 5*time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── PocketBase admin helpers ─────────────────────────────────────────────────
|
||||
|
||||
// pbAuthToken obtains a PocketBase superuser JWT.
|
||||
func pbAuthToken(ctx context.Context, f *e2eFixture) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"identity": f.pbEmail,
|
||||
"password": f.pbPassword,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
f.pbBaseURL+"/api/collections/_superusers/auth-with-password",
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pbAuthToken: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("pbAuthToken status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("pbAuthToken decode: %w", err)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// createAppUser inserts a record into app_users via PocketBase admin API.
|
||||
func createAppUser(ctx context.Context, f *e2eFixture, username, passwordHash, role string) error {
|
||||
tok, err := pbAuthToken(ctx, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"username": username,
|
||||
"password_hash": passwordHash,
|
||||
"role": role,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
f.pbBaseURL+"/api/collections/app_users/records",
|
||||
bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", tok)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("createAppUser: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("createAppUser status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAppUserByUsername fetches an app_users record by username.
|
||||
// Returns nil, nil when not found.
|
||||
func getAppUserByUsername(ctx context.Context, f *e2eFixture, username string) (map[string]interface{}, error) {
|
||||
tok, err := pbAuthToken(ctx, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=1",
|
||||
f.pbBaseURL, username)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", tok)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getAppUserByUsername: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("getAppUserByUsername decode: %w", err)
|
||||
}
|
||||
if len(result.Items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return result.Items[0], nil
|
||||
}
|
||||
|
||||
// deleteAppUser removes app_users records matching username.
|
||||
func deleteAppUser(t *testing.T, f *e2eFixture, ctx context.Context, username string) {
|
||||
t.Helper()
|
||||
tok, err := pbAuthToken(ctx, f)
|
||||
if err != nil {
|
||||
t.Logf("deleteAppUser: pbAuthToken error: %v", err)
|
||||
return
|
||||
}
|
||||
// List matching records.
|
||||
url := fmt.Sprintf("%s/api/collections/app_users/records?filter=username%%3D%%22%s%%22&perPage=10",
|
||||
f.pbBaseURL, username)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", tok)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("deleteAppUser list error: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
for _, item := range result.Items {
|
||||
id, _ := item["id"].(string)
|
||||
delURL := fmt.Sprintf("%s/api/collections/app_users/records/%s", f.pbBaseURL, id)
|
||||
delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil)
|
||||
delReq.Header.Set("Authorization", tok)
|
||||
delResp, _ := http.DefaultClient.Do(delReq)
|
||||
if delResp != nil {
|
||||
delResp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupTestData removes all PocketBase + MinIO data for the given slug.
|
||||
func cleanupTestData(t *testing.T, f *e2eFixture, ctx context.Context, slug string) {
|
||||
t.Helper()
|
||||
tok, err := pbAuthToken(ctx, f)
|
||||
if err != nil {
|
||||
t.Logf("cleanupTestData: pbAuthToken error: %v", err)
|
||||
return
|
||||
}
|
||||
pbDelete := func(collection, filter string) {
|
||||
listURL := fmt.Sprintf("%s/api/collections/%s/records?filter=%s&perPage=500",
|
||||
f.pbBaseURL, collection, filter)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
|
||||
req.Header.Set("Authorization", tok)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("cleanupTestData list %s error: %v", collection, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
for _, item := range result.Items {
|
||||
id, _ := item["id"].(string)
|
||||
delURL := fmt.Sprintf("%s/api/collections/%s/records/%s", f.pbBaseURL, collection, id)
|
||||
delReq, _ := http.NewRequestWithContext(ctx, http.MethodDelete, delURL, nil)
|
||||
delReq.Header.Set("Authorization", tok)
|
||||
delResp, _ := http.DefaultClient.Do(delReq)
|
||||
if delResp != nil {
|
||||
delResp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
slugFilter := fmt.Sprintf("slug%%3D%%22%s%%22", slug)
|
||||
ckFilter := fmt.Sprintf("cache_key%%7E%%22%s%%2F%%22", slug) // cache_key ~ "slug/"
|
||||
pbDelete("books", slugFilter)
|
||||
pbDelete("chapters_idx", slugFilter)
|
||||
pbDelete("audio_cache", ckFilter)
|
||||
t.Logf("cleanup complete for slug=%q", slug)
|
||||
}
|
||||
|
||||
// ─── HTTP assertion helpers ───────────────────────────────────────────────────
|
||||
|
||||
// checkHTTP asserts that a GET to url returns 2xx within the context deadline.
|
||||
func checkHTTP(t *testing.T, ctx context.Context, url, name string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("%s health check: build request: %v", name, err)
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("%s health check failed: %v", name, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Errorf("%s health check: status %d, want 2xx", name, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode)
|
||||
}
|
||||
|
||||
// waitForHTTP retries GET url until a 2xx is received or timeout is reached.
|
||||
func waitForHTTP(t *testing.T, ctx context.Context, url, name string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Errorf("%s: context cancelled while waiting for health", name)
|
||||
return
|
||||
default:
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
resp.Body.Close()
|
||||
t.Logf("%s health OK (HTTP %d)", name, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("status %d", resp.StatusCode)
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Errorf("%s not healthy after %s: %v", name, timeout, lastErr)
|
||||
}
|
||||
|
||||
// fetchPresignedURL calls the presign endpoint and returns the presigned URL.
|
||||
// It logs and returns "" on failure (non-fatal) so the caller can decide.
|
||||
func fetchPresignedURL(t *testing.T, ctx context.Context, presignEndpoint, label string) string {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, presignEndpoint, nil)
|
||||
if err != nil {
|
||||
t.Errorf("fetchPresignedURL %s: %v", label, err)
|
||||
return ""
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("fetchPresignedURL %s: %v", label, err)
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("fetchPresignedURL %s: status %d body=%s", label, resp.StatusCode, b)
|
||||
return ""
|
||||
}
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Errorf("fetchPresignedURL %s decode: %v", label, err)
|
||||
return ""
|
||||
}
|
||||
if body.URL == "" {
|
||||
t.Errorf("fetchPresignedURL %s: empty url in response", label)
|
||||
return ""
|
||||
}
|
||||
t.Logf("%s presigned URL: %s", label, body.URL)
|
||||
return body.URL
|
||||
}
|
||||
|
||||
// assertURLAccessible does a GET to url and asserts HTTP 200.
|
||||
func assertURLAccessible(t *testing.T, ctx context.Context, url, label string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("%s: build request: %v", label, err)
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("%s: GET error: %v", label, err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s: status %d, want 200", label, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
t.Logf("%s: HTTP 200 OK", label)
|
||||
}
|
||||
|
||||
// assertURLAccessibleWithRetry retries GET url up to maxAttempts times with
|
||||
// interval between attempts, asserting HTTP 200 on any success.
|
||||
func assertURLAccessibleWithRetry(t *testing.T, ctx context.Context, url, label string, maxAttempts int, interval time.Duration) {
|
||||
t.Helper()
|
||||
var lastStatus int
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Errorf("%s: build request: %v", label, err)
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("%s: attempt %d GET error: %v", label, attempt, err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
lastStatus = resp.StatusCode
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Logf("%s: HTTP 200 OK (attempt %d)", label, attempt)
|
||||
return
|
||||
}
|
||||
t.Logf("%s: attempt %d status %d", label, attempt, resp.StatusCode)
|
||||
}
|
||||
if attempt < maxAttempts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Errorf("%s: context cancelled before success", label)
|
||||
return
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Errorf("%s: status %d after %d attempts, want 200", label, lastStatus, maxAttempts)
|
||||
}
|
||||
|
||||
// ─── stdlib helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func chapterNumbers(refs []scraper.ChapterRef) []int {
|
||||
ns := make([]int, len(refs))
|
||||
for i, r := range refs {
|
||||
ns[i] = r.Number
|
||||
}
|
||||
return ns
|
||||
}
|
||||
|
||||
// scrapeChapterListPage1 fetches a single chapter-list page URL via Browserless
|
||||
// and returns the chapter refs found on that page (no pagination).
|
||||
// URL should be: https://novelfire.net/book/{slug}/chapters?page=1
|
||||
func scrapeChapterListPage1(ctx context.Context, f *e2eFixture, pageURL string) ([]scraper.ChapterRef, error) {
|
||||
return f.sc.ScrapeChapterListPage(ctx, pageURL)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ package novelfire
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -51,7 +52,8 @@ func newIntegrationScraper(t *testing.T) *Scraper {
|
||||
Timeout: 120 * time.Second,
|
||||
MaxConcurrent: 1,
|
||||
})
|
||||
return New(client, nil)
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
return New(client, log, client, nil, nil)
|
||||
}
|
||||
|
||||
// ── Metadata ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,14 +2,9 @@ package novelfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/libnovel/scraper/internal/browser"
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/writer"
|
||||
)
|
||||
|
||||
// rankingPage1HTML is a realistic mock of the popular genre listing page
|
||||
@@ -116,7 +111,7 @@ func TestScrapeRanking_MultiPage(t *testing.T) {
|
||||
// Use pagedStubClient for s.client so each GetContent call returns the
|
||||
// next page. ScrapeRanking now calls s.client directly.
|
||||
urlClient := &pagedStubClient{pages: []string{rankingPage1HTML(), rankingPage2HTML()}}
|
||||
s := New(urlClient, nil, nil, nil) // nil cache — no disk I/O in tests
|
||||
s := New(urlClient, nil, nil, nil, nil) // nil cache — no disk I/O in tests
|
||||
|
||||
entryCh, errCh := s.ScrapeRanking(context.Background(), 0) // 0 = all pages
|
||||
entries := drainRanking(t, entryCh, errCh)
|
||||
@@ -151,146 +146,3 @@ func TestScrapeRanking_EmptyPage(t *testing.T) {
|
||||
t.Errorf("expected 0 entries for empty page, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteRanking_RoundTrip verifies WriteRanking → ReadRankingItems
|
||||
// faithfully reconstructs the original slice.
|
||||
func TestWriteRanking_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := writer.New(dir)
|
||||
|
||||
items := []writer.RankingItem{
|
||||
{Rank: 1, Slug: "the-iron-throne", Title: "The Iron Throne", Status: "Ongoing",
|
||||
Genres: []string{"Fantasy", "Action"}, SourceURL: "https://novelfire.net/book/the-iron-throne"},
|
||||
{Rank: 2, Slug: "shadow-mage", Title: "Shadow Mage", Status: "Completed",
|
||||
Genres: []string{"Magic"}, SourceURL: "https://novelfire.net/book/shadow-mage"},
|
||||
}
|
||||
|
||||
if err := w.WriteRanking(items); err != nil {
|
||||
t.Fatalf("WriteRanking failed: %v", err)
|
||||
}
|
||||
|
||||
rankingFile := filepath.Join(dir, "ranking.json")
|
||||
if _, err := os.Stat(rankingFile); err != nil {
|
||||
t.Fatalf("ranking.json not created: %v", err)
|
||||
}
|
||||
|
||||
got, err := w.ReadRankingItems()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRankingItems failed: %v", err)
|
||||
}
|
||||
if len(got) != len(items) {
|
||||
t.Fatalf("expected %d items, got %d", len(items), len(got))
|
||||
}
|
||||
for i, want := range items {
|
||||
if got[i].Rank != want.Rank {
|
||||
t.Errorf("item[%d].Rank = %d, want %d", i, got[i].Rank, want.Rank)
|
||||
}
|
||||
if got[i].Slug != want.Slug {
|
||||
t.Errorf("item[%d].Slug = %q, want %q", i, got[i].Slug, want.Slug)
|
||||
}
|
||||
if got[i].Title != want.Title {
|
||||
t.Errorf("item[%d].Title = %q, want %q", i, got[i].Title, want.Title)
|
||||
}
|
||||
if got[i].Status != want.Status {
|
||||
t.Errorf("item[%d].Status = %q, want %q", i, got[i].Status, want.Status)
|
||||
}
|
||||
if len(got[i].Genres) != len(want.Genres) {
|
||||
t.Errorf("item[%d].Genres len = %d, want %d", i, len(got[i].Genres), len(want.Genres))
|
||||
} else {
|
||||
for j, g := range want.Genres {
|
||||
if got[i].Genres[j] != g {
|
||||
t.Errorf("item[%d].Genres[%d] = %q, want %q", i, j, got[i].Genres[j], g)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got[i].SourceURL != want.SourceURL {
|
||||
t.Errorf("item[%d].SourceURL = %q, want %q", i, got[i].SourceURL, want.SourceURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── in-memory page cacher ─────────────────────────────────────────────────────
|
||||
|
||||
// memPageCacher is a RankingPageCacher backed by an in-memory map.
|
||||
// It records how many times each page was written and exposes the stored HTML.
|
||||
type memPageCacher struct {
|
||||
pages map[int]string
|
||||
writes map[int]int
|
||||
}
|
||||
|
||||
func newMemPageCacher() *memPageCacher {
|
||||
return &memPageCacher{pages: make(map[int]string), writes: make(map[int]int)}
|
||||
}
|
||||
|
||||
func (c *memPageCacher) WriteRankingPageCache(page int, html string) error {
|
||||
c.pages[page] = html
|
||||
c.writes[page]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memPageCacher) ReadRankingPageCache(page int) (string, error) {
|
||||
return c.pages[page], nil // returns "" on miss, satisfying the interface contract
|
||||
}
|
||||
|
||||
var _ scraper.RankingPageCacher = (*memPageCacher)(nil) // compile-time check
|
||||
|
||||
// TestScrapeRanking_CacheHit verifies that when a page is already in the cache
|
||||
// ScrapeRanking serves from cache and does NOT call the browser client.
|
||||
func TestScrapeRanking_CacheHit(t *testing.T) {
|
||||
cache := newMemPageCacher()
|
||||
// Pre-populate the cache with page 1 HTML.
|
||||
if err := cache.WriteRankingPageCache(1, rankingPage1HTML()); err != nil {
|
||||
t.Fatalf("cache write: %v", err)
|
||||
}
|
||||
cache.writes[1] = 0 // reset write counter — we only care about fetches
|
||||
|
||||
// The stub client panics on any GetContent call so we can prove it is not used.
|
||||
panicClient := &panicOnGetContent{}
|
||||
s := New(panicClient, nil, panicClient, cache)
|
||||
|
||||
entryCh, errCh := s.ScrapeRanking(context.Background(), 1)
|
||||
entries := drainRanking(t, entryCh, errCh)
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries from cache, got %d", len(entries))
|
||||
}
|
||||
// Cache should not have been written again (we served from cache).
|
||||
if cache.writes[1] != 0 {
|
||||
t.Errorf("expected 0 cache writes on a hit, got %d", cache.writes[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrapeRanking_CacheMiss verifies that on a cache miss the page is fetched
|
||||
// from the network and the result is written to the cache.
|
||||
func TestScrapeRanking_CacheMiss(t *testing.T) {
|
||||
cache := newMemPageCacher() // empty cache
|
||||
s := New(&stubClient{html: rankingPage1HTML()}, nil, nil, cache)
|
||||
|
||||
entryCh, errCh := s.ScrapeRanking(context.Background(), 1)
|
||||
entries := drainRanking(t, entryCh, errCh)
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
if cache.writes[1] != 1 {
|
||||
t.Errorf("expected 1 cache write on a miss, got %d", cache.writes[1])
|
||||
}
|
||||
if cache.pages[1] == "" {
|
||||
t.Error("expected page 1 to be stored in cache after miss")
|
||||
}
|
||||
}
|
||||
|
||||
// panicOnGetContent is a BrowserClient whose GetContent panics, letting tests
|
||||
// assert that it is never called (i.e. the cache was used instead).
|
||||
type panicOnGetContent struct{}
|
||||
|
||||
func (p *panicOnGetContent) Strategy() browser.Strategy { return browser.StrategyContent }
|
||||
func (p *panicOnGetContent) GetContent(_ context.Context, req browser.ContentRequest) (string, error) {
|
||||
panic(fmt.Sprintf("unexpected GetContent call for URL %s — should have been served from cache", req.URL))
|
||||
}
|
||||
func (p *panicOnGetContent) ScrapePage(_ context.Context, _ browser.ScrapeRequest) (browser.ScrapeResponse, error) {
|
||||
return browser.ScrapeResponse{}, nil
|
||||
}
|
||||
func (p *panicOnGetContent) CDPSession(_ context.Context, _ string, _ browser.CDPSessionFunc) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,46 +30,38 @@ const (
|
||||
rankingPath = "/genre-all/sort-popular/status-all/all-novel"
|
||||
)
|
||||
|
||||
// rejectResourceTypes lists Browserless resource types to block on every request.
|
||||
// We keep: document (the page), script (JS renders the DOM), fetch/xhr (JS data calls).
|
||||
// Everything else is safe to drop for HTML-only scraping.
|
||||
var rejectResourceTypes = []string{
|
||||
"cspviolationreport",
|
||||
"eventsource",
|
||||
"fedcm",
|
||||
"font",
|
||||
"image",
|
||||
"manifest",
|
||||
"media",
|
||||
"other",
|
||||
"ping",
|
||||
"signedexchange",
|
||||
"stylesheet",
|
||||
"texttrack",
|
||||
"websocket",
|
||||
// RankingStore is the subset of storage.Store consumed by ScrapeRanking.
|
||||
type RankingStore interface {
|
||||
WriteRankingItem(ctx context.Context, item scraper.RankingItem) error
|
||||
RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
// Scraper is the novelfire.net implementation of scraper.NovelScraper.
|
||||
// It uses the /content strategy by default (rendered HTML via Browserless).
|
||||
// It uses direct HTTP requests (no headless browser required).
|
||||
type Scraper struct {
|
||||
client browser.BrowserClient
|
||||
urlClient browser.BrowserClient // separate client for URL retrieval (uses browserless content strategy)
|
||||
pageCache scraper.RankingPageCacher
|
||||
log *slog.Logger
|
||||
client browser.BrowserClient
|
||||
urlClient browser.BrowserClient // used for chapter list pagination
|
||||
chapterClient browser.BrowserClient // used for chapter text fetching
|
||||
rankingStore RankingStore
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a new novelfire Scraper.
|
||||
// client is used for content fetching, urlClient is used for URL retrieval (chapter list).
|
||||
// If urlClient is nil, client will be used for both.
|
||||
// pageCache is optional; pass nil to disable ranking page caching.
|
||||
func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, pageCache scraper.RankingPageCacher) *Scraper {
|
||||
// client is used for catalogue/metadata/ranking fetching (direct HTTP).
|
||||
// urlClient is used for chapter list pagination; falls back to client if nil.
|
||||
// chapterClient is used for chapter text fetching; falls back to client if nil.
|
||||
// rankingStore is optional; pass nil to disable freshness checks and per-item persistence.
|
||||
func New(client browser.BrowserClient, log *slog.Logger, urlClient browser.BrowserClient, chapterClient browser.BrowserClient, rankingStore RankingStore) *Scraper {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if urlClient == nil {
|
||||
urlClient = client
|
||||
}
|
||||
return &Scraper{client: client, urlClient: urlClient, pageCache: pageCache, log: log}
|
||||
if chapterClient == nil {
|
||||
chapterClient = client
|
||||
}
|
||||
return &Scraper{client: client, urlClient: urlClient, chapterClient: chapterClient, rankingStore: rankingStore, log: log}
|
||||
}
|
||||
|
||||
// SourceName implements NovelScraper.
|
||||
@@ -97,18 +89,9 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue
|
||||
}
|
||||
|
||||
s.log.Info("scraping catalogue page", "page", page, "url", pageURL)
|
||||
s.log.Debug("catalogue page fetch starting",
|
||||
"page", page,
|
||||
"payload_url", pageURL,
|
||||
"payload_wait_selector", ".novel-item",
|
||||
"payload_wait_selector_timeout_ms", 5000,
|
||||
)
|
||||
|
||||
html, err := s.client.GetContent(ctx, browser.ContentRequest{
|
||||
URL: pageURL,
|
||||
WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 5000},
|
||||
RejectResourceTypes: rejectResourceTypes,
|
||||
GotoOptions: &browser.GotoOptions{Timeout: 60000},
|
||||
URL: pageURL,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("catalogue page fetch failed",
|
||||
@@ -131,24 +114,28 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue
|
||||
return
|
||||
}
|
||||
|
||||
// Extract novel cards: <div class="novel-item">
|
||||
cards := htmlutil.FindAll(root, scraper.Selector{Tag: "div", Class: "novel-item", Multiple: true})
|
||||
// Extract novel cards: <li class="novel-item">
|
||||
// <a href="/book/slug" title="Title">
|
||||
// <figure class="novel-cover"><img data-src="..."></figure>
|
||||
// <h4 class="novel-title text2row">Title</h4>
|
||||
// </a>
|
||||
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 {
|
||||
// Title: <h3 class="novel-title"><a href="/book/slug">Title</a>
|
||||
titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h3", Class: "novel-title"})
|
||||
// The outer <a> carries the href; <h4 class="novel-title"> has the title text.
|
||||
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 {
|
||||
linkNode := htmlutil.FindFirst(titleNode, scraper.Selector{Tag: "a", Attr: "href"})
|
||||
if linkNode != nil {
|
||||
title = htmlutil.ExtractText(linkNode, scraper.Selector{})
|
||||
href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"})
|
||||
}
|
||||
title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{}))
|
||||
}
|
||||
if href == "" || title == "" {
|
||||
continue
|
||||
@@ -162,8 +149,17 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue
|
||||
}
|
||||
}
|
||||
|
||||
// Find next page link: <a class="next" href="...">
|
||||
nextHref := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "a", Class: "next", Attr: "href"})
|
||||
// Find next page link: <a rel="next" href="..."> (same structure as ranking pages)
|
||||
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
|
||||
}
|
||||
@@ -178,17 +174,10 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan scraper.Catalogue
|
||||
// ─── MetadataProvider ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (scraper.BookMeta, error) {
|
||||
s.log.Debug("metadata fetch starting",
|
||||
"payload_url", bookURL,
|
||||
"payload_wait_selector", ".novel-title",
|
||||
"payload_wait_selector_timeout_ms", 5000,
|
||||
)
|
||||
s.log.Debug("metadata fetch starting", "url", bookURL)
|
||||
|
||||
raw, err := s.client.GetContent(ctx, browser.ContentRequest{
|
||||
URL: bookURL,
|
||||
WaitFor: &browser.WaitForSelector{Selector: ".novel-title", Timeout: 5000},
|
||||
RejectResourceTypes: rejectResourceTypes,
|
||||
GotoOptions: &browser.GotoOptions{Timeout: 60000},
|
||||
URL: bookURL,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("metadata fetch failed", "url", bookURL, "err", err)
|
||||
@@ -209,6 +198,9 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (scraper.B
|
||||
var cover string
|
||||
if figureCover := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); figureCover != nil {
|
||||
cover = htmlutil.ExtractFirst(figureCover, scraper.Selector{Tag: "img", Attr: "src"})
|
||||
if cover != "" && !strings.HasPrefix(cover, "http") {
|
||||
cover = baseURL + cover
|
||||
}
|
||||
}
|
||||
// <span class="status">Ongoing</span>
|
||||
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
|
||||
@@ -272,24 +264,11 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scra
|
||||
s.log.Debug("chapter list fetch starting",
|
||||
"page", page,
|
||||
"payload_url", pageURL,
|
||||
"payload_wait_selector", ".chapter-list",
|
||||
"payload_wait_selector_timeout_ms", 15000,
|
||||
"payload_wait_timeout_ms", 2000,
|
||||
"strategy", s.urlClient.Strategy(),
|
||||
)
|
||||
|
||||
raw, err := s.urlClient.GetContent(ctx, browser.ContentRequest{
|
||||
URL: pageURL,
|
||||
// Wait up to 15 s for the chapter list container to appear in the DOM.
|
||||
WaitFor: &browser.WaitForSelector{Selector: ".chapter-list", Timeout: 15000},
|
||||
// After the selector is found, wait an additional 2 s for any
|
||||
// deferred JS rendering (lazy-loaded links, infinite-scroll hydration).
|
||||
WaitForTimeout: 2000,
|
||||
RejectResourceTypes: rejectResourceTypes,
|
||||
GotoOptions: &browser.GotoOptions{Timeout: 60000},
|
||||
// Do NOT use BestAttempt — we want a complete page or a clear error,
|
||||
// not silently partial HTML that looks like "no more chapters".
|
||||
BestAttempt: false,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("chapter list fetch failed",
|
||||
@@ -366,6 +345,57 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]scra
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// ScrapeChapterListPage fetches and parses a single chapter-list page URL and
|
||||
// returns all chapter refs found on that page without following pagination.
|
||||
// pageURL should be the full URL including query params, e.g.:
|
||||
//
|
||||
// https://novelfire.net/book/shadow-slave/chapters?page=1
|
||||
func (s *Scraper) ScrapeChapterListPage(ctx context.Context, pageURL string) ([]scraper.ChapterRef, error) {
|
||||
s.log.Info("scraping chapter list page (single)", "url", pageURL)
|
||||
|
||||
raw, err := s.urlClient.GetContent(ctx, browser.ContentRequest{
|
||||
URL: pageURL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chapter list page fetch: %w", err)
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chapter list page parse: %w", err)
|
||||
}
|
||||
|
||||
chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"})
|
||||
if chapterList == nil {
|
||||
return nil, fmt.Errorf("chapter list container not found in %s", pageURL)
|
||||
}
|
||||
|
||||
items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"})
|
||||
var refs []scraper.ChapterRef
|
||||
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
|
||||
}
|
||||
refs = append(refs, scraper.ChapterRef{
|
||||
Number: num,
|
||||
Title: strings.TrimSpace(chTitle),
|
||||
URL: chURL,
|
||||
})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// ─── RankingProvider ───────────────────────────────────────────────────────────
|
||||
|
||||
// hasNextPageLink returns true if the HTML document contains a pagination link
|
||||
@@ -388,6 +418,9 @@ func hasNextPageLink(root *html.Node) bool {
|
||||
// listing on novelfire.net (/genre-all/sort-popular/status-all/all-novel).
|
||||
// Pages are fetched one at a time, strictly sequentially.
|
||||
// maxPages <= 0 means "fetch all pages until no more are found".
|
||||
//
|
||||
// If a RankingStore was provided and the stored ranking is fresh (< 24 hours old),
|
||||
// both channels are closed immediately without any network traffic.
|
||||
func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scraper.BookMeta, <-chan error) {
|
||||
entries := make(chan scraper.BookMeta, 32)
|
||||
errs := make(chan error, 16)
|
||||
@@ -396,6 +429,17 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap
|
||||
defer close(entries)
|
||||
defer close(errs)
|
||||
|
||||
// Freshness check: skip scraping if data is recent enough.
|
||||
if s.rankingStore != nil {
|
||||
fresh, err := s.rankingStore.RankingFreshEnough(ctx, 24*time.Hour)
|
||||
if err != nil {
|
||||
s.log.Warn("ranking freshness check failed, proceeding with scrape", "err", err)
|
||||
} else if fresh {
|
||||
s.log.Info("ranking data is fresh, skipping scrape")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rank := 1
|
||||
|
||||
for page := 1; maxPages <= 0 || page <= maxPages; page++ {
|
||||
@@ -407,38 +451,14 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap
|
||||
|
||||
pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page)
|
||||
|
||||
// Try to serve from disk cache before hitting the network.
|
||||
var raw string
|
||||
if s.pageCache != nil {
|
||||
if cached, err := s.pageCache.ReadRankingPageCache(page); err != nil {
|
||||
s.log.Warn("ranking page cache read error", "page", page, "err", err)
|
||||
} else if cached != "" {
|
||||
s.log.Info("serving ranking page from cache", "page", page)
|
||||
raw = cached
|
||||
}
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
s.log.Info("scraping popular ranking page", "page", page, "url", pageURL)
|
||||
fetched, err := s.client.GetContent(ctx, browser.ContentRequest{
|
||||
URL: pageURL,
|
||||
WaitFor: &browser.WaitForSelector{Selector: ".novel-item", Timeout: 5000},
|
||||
RejectResourceTypes: rejectResourceTypes,
|
||||
GotoOptions: &browser.GotoOptions{Timeout: 60000},
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("ranking page fetch failed", "page", page, "url", pageURL, "err", err)
|
||||
errs <- fmt.Errorf("ranking page %d: %w", page, err)
|
||||
return
|
||||
}
|
||||
raw = fetched
|
||||
|
||||
// Persist to cache for future runs.
|
||||
if s.pageCache != nil {
|
||||
if werr := s.pageCache.WriteRankingPageCache(page, raw); werr != nil {
|
||||
s.log.Warn("ranking page cache write error", "page", page, "err", werr)
|
||||
}
|
||||
}
|
||||
s.log.Info("scraping popular ranking page", "page", page, "url", pageURL)
|
||||
raw, err := s.client.GetContent(ctx, browser.ContentRequest{
|
||||
URL: pageURL,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("ranking page fetch failed", "page", page, "url", pageURL, "err", err)
|
||||
errs <- fmt.Errorf("ranking page %d: %w", page, err)
|
||||
return
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
@@ -497,10 +517,10 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap
|
||||
}
|
||||
}
|
||||
|
||||
slug := slugFromURL(bookURL)
|
||||
bookSlug := slugFromURL(bookURL)
|
||||
|
||||
meta := scraper.BookMeta{
|
||||
Slug: slug,
|
||||
Slug: bookSlug,
|
||||
Title: title,
|
||||
Cover: cover,
|
||||
SourceURL: bookURL,
|
||||
@@ -508,6 +528,20 @@ func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan scrap
|
||||
}
|
||||
rank++
|
||||
|
||||
// Persist item to store immediately.
|
||||
if s.rankingStore != nil {
|
||||
item := scraper.RankingItem{
|
||||
Rank: meta.Ranking,
|
||||
Slug: meta.Slug,
|
||||
Title: meta.Title,
|
||||
Cover: meta.Cover,
|
||||
SourceURL: meta.SourceURL,
|
||||
}
|
||||
if werr := s.rankingStore.WriteRankingItem(ctx, item); werr != nil {
|
||||
s.log.Warn("ranking item write failed", "slug", meta.Slug, "err", werr)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -583,12 +617,8 @@ func (s *Scraper) ScrapeChapterText(ctx context.Context, ref scraper.ChapterRef)
|
||||
"payload_wait_selector_timeout_ms", 5000,
|
||||
)
|
||||
|
||||
raw, err := retryGetContent(ctx, s.log, s.client, browser.ContentRequest{
|
||||
URL: ref.URL,
|
||||
WaitFor: &browser.WaitForSelector{Selector: "#content", Timeout: 5000},
|
||||
RejectResourceTypes: rejectResourceTypes,
|
||||
GotoOptions: &browser.GotoOptions{Timeout: 60000},
|
||||
BestAttempt: true,
|
||||
raw, err := retryGetContent(ctx, s.log, s.chapterClient, browser.ContentRequest{
|
||||
URL: ref.URL,
|
||||
}, 9, 6*time.Second)
|
||||
if err != nil {
|
||||
s.log.Debug("chapter text fetch failed",
|
||||
@@ -643,20 +673,8 @@ func (s *Scraper) ScrapeChapterText(ctx context.Context, ref scraper.ChapterRef)
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
}
|
||||
// resolveURL is a thin alias over htmlutil.ResolveURL kept for readability.
|
||||
func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) }
|
||||
|
||||
func slugFromURL(bookURL string) string {
|
||||
u, err := url.Parse(bookURL)
|
||||
|
||||
@@ -62,12 +62,12 @@ func (c *pagedStubClient) CDPSession(_ context.Context, _ string, _ browser.CDPS
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func newScraper(html string) *Scraper {
|
||||
return New(&stubClient{html: html}, nil, &stubClient{html: html}, nil)
|
||||
return New(&stubClient{html: html}, nil, &stubClient{html: html}, nil, nil)
|
||||
}
|
||||
|
||||
func newPagedScraper(pages ...string) *Scraper {
|
||||
urlClient := &pagedStubClient{pages: pages}
|
||||
return New(&stubClient{}, nil, urlClient, nil)
|
||||
return New(&stubClient{}, nil, urlClient, nil, nil)
|
||||
}
|
||||
|
||||
// ── ScrapeChapterText ─────────────────────────────────────────────────────────
|
||||
@@ -141,6 +141,84 @@ func TestChapterNumberFromURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScrapeMetadata ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScrapeMetadata_ParsesFields(t *testing.T) {
|
||||
html := `<!DOCTYPE html><html><body>
|
||||
<h1 class="novel-title">The Iron Throne</h1>
|
||||
<span class="author"><a>Jane Doe</a></span>
|
||||
<figure class="cover"><img src="https://cdn.example.com/cover.jpg"></figure>
|
||||
<span class="status">Ongoing</span>
|
||||
<div class="genres"><a>Fantasy</a><a>Action</a></div>
|
||||
<div class="summary"><p>A sweeping epic set in a magical world.</p></div>
|
||||
<span class="chapter-count">42 Chapters</span>
|
||||
</body></html>`
|
||||
|
||||
s := newScraper(html)
|
||||
meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/the-iron-throne")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if meta.Slug != "the-iron-throne" {
|
||||
t.Errorf("Slug = %q, want %q", meta.Slug, "the-iron-throne")
|
||||
}
|
||||
if meta.Title != "The Iron Throne" {
|
||||
t.Errorf("Title = %q, want %q", meta.Title, "The Iron Throne")
|
||||
}
|
||||
if meta.Author != "Jane Doe" {
|
||||
t.Errorf("Author = %q, want %q", meta.Author, "Jane Doe")
|
||||
}
|
||||
if meta.Cover != "https://cdn.example.com/cover.jpg" {
|
||||
t.Errorf("Cover = %q, want %q", meta.Cover, "https://cdn.example.com/cover.jpg")
|
||||
}
|
||||
if meta.Status != "Ongoing" {
|
||||
t.Errorf("Status = %q, want %q", meta.Status, "Ongoing")
|
||||
}
|
||||
if len(meta.Genres) != 2 || meta.Genres[0] != "Fantasy" || meta.Genres[1] != "Action" {
|
||||
t.Errorf("Genres = %v, want [Fantasy Action]", meta.Genres)
|
||||
}
|
||||
if !strings.Contains(meta.Summary, "sweeping epic") {
|
||||
t.Errorf("Summary = %q, want it to contain 'sweeping epic'", meta.Summary)
|
||||
}
|
||||
if meta.TotalChapters != 42 {
|
||||
t.Errorf("TotalChapters = %d, want 42", meta.TotalChapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeMetadata_RelativeCoverURL(t *testing.T) {
|
||||
html := `<!DOCTYPE html><html><body>
|
||||
<h1 class="novel-title">Relative Cover</h1>
|
||||
<figure class="cover"><img src="/images/cover.jpg"></figure>
|
||||
</body></html>`
|
||||
|
||||
s := newScraper(html)
|
||||
meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/relative-cover")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Relative cover URL should be resolved against the base domain.
|
||||
if !strings.HasPrefix(meta.Cover, "https://novelfire.net") {
|
||||
t.Errorf("Cover = %q, expected it to be resolved to an absolute URL", meta.Cover)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeMetadata_MissingFields(t *testing.T) {
|
||||
// Minimal page — everything absent; should succeed without panicking.
|
||||
html := `<!DOCTYPE html><html><body></body></html>`
|
||||
|
||||
s := newScraper(html)
|
||||
meta, err := s.ScrapeMetadata(context.Background(), "https://novelfire.net/book/empty-novel")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if meta.Slug != "empty-novel" {
|
||||
t.Errorf("Slug = %q, want %q", meta.Slug, "empty-novel")
|
||||
}
|
||||
if meta.TotalChapters != 0 {
|
||||
t.Errorf("TotalChapters = %d, want 0 for missing chapter-count", meta.TotalChapters)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScrapeChapterList (position vs URL numbering) ─────────────────────────────
|
||||
|
||||
// TestScrapeChapterList_NumbersFromURL verifies that when the chapter list HTML
|
||||
|
||||
@@ -17,36 +17,58 @@ import (
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/writer"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// Progress is a snapshot of counters at a point in time.
|
||||
type Progress struct {
|
||||
BooksFound int
|
||||
ChaptersScraped int
|
||||
ChaptersSkipped int
|
||||
Errors int
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// StaticRoot is the path to the static/books output directory.
|
||||
// StaticRoot is kept for backwards-compatibility but is no longer used
|
||||
// when a Store is provided.
|
||||
StaticRoot string
|
||||
|
||||
// SingleBookURL when non-empty causes the orchestrator to scrape only
|
||||
// that one book instead of walking the full catalogue.
|
||||
SingleBookURL string
|
||||
|
||||
// FromChapter, when > 0, skips chapters with number < FromChapter.
|
||||
// Only effective in single-book mode.
|
||||
FromChapter int
|
||||
|
||||
// ToChapter, when > 0, skips chapters with number > ToChapter.
|
||||
// Only effective in single-book mode. 0 means "no upper limit".
|
||||
ToChapter int
|
||||
|
||||
// OnProgress is called periodically with the current progress counters.
|
||||
// It is always called on completion (success or failure). May be nil.
|
||||
OnProgress func(p Progress)
|
||||
}
|
||||
|
||||
// Orchestrator coordinates the full scrape pipeline.
|
||||
type Orchestrator struct {
|
||||
cfg Config
|
||||
novel scraper.NovelScraper
|
||||
writer *writer.Writer
|
||||
store storage.Store
|
||||
log *slog.Logger
|
||||
workers int
|
||||
}
|
||||
|
||||
// New returns a new Orchestrator.
|
||||
func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger) *Orchestrator {
|
||||
// New returns a new Orchestrator backed by the provided Store.
|
||||
func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store) *Orchestrator {
|
||||
workers := cfg.Workers
|
||||
if workers <= 0 {
|
||||
workers = runtime.NumCPU()
|
||||
@@ -54,7 +76,7 @@ func New(cfg Config, novel scraper.NovelScraper, log *slog.Logger) *Orchestrator
|
||||
return &Orchestrator{
|
||||
cfg: cfg,
|
||||
novel: novel,
|
||||
writer: writer.New(cfg.StaticRoot),
|
||||
store: store,
|
||||
log: log,
|
||||
workers: workers,
|
||||
}
|
||||
@@ -66,9 +88,31 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
o.log.Info("orchestrator starting",
|
||||
"source", o.novel.SourceName(),
|
||||
"workers", o.workers,
|
||||
"static_root", o.cfg.StaticRoot,
|
||||
)
|
||||
|
||||
// Atomic counters updated by concurrent goroutines.
|
||||
var (
|
||||
booksFound atomic.Int64
|
||||
chaptersScraped atomic.Int64
|
||||
chaptersSkipped atomic.Int64
|
||||
errors atomic.Int64
|
||||
)
|
||||
|
||||
snapshot := func() Progress {
|
||||
return Progress{
|
||||
BooksFound: int(booksFound.Load()),
|
||||
ChaptersScraped: int(chaptersScraped.Load()),
|
||||
ChaptersSkipped: int(chaptersSkipped.Load()),
|
||||
Errors: int(errors.Load()),
|
||||
}
|
||||
}
|
||||
|
||||
notify := func() {
|
||||
if o.cfg.OnProgress != nil {
|
||||
o.cfg.OnProgress(snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// chapterWork is the shared queue consumed by chapter worker goroutines.
|
||||
type chapterJob struct {
|
||||
slug string
|
||||
@@ -89,10 +133,12 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
default:
|
||||
}
|
||||
|
||||
// Skip if already on disk.
|
||||
if o.writer.ChapterExists(job.slug, job.ref) {
|
||||
// Skip if already stored.
|
||||
if o.store.ChapterExists(ctx, job.slug, job.ref) {
|
||||
o.log.Debug("chapter already exists, skipping",
|
||||
"book", job.slug, "chapter", job.ref.Number)
|
||||
chaptersSkipped.Add(1)
|
||||
notify()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -104,18 +150,24 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
"url", job.ref.URL,
|
||||
"err", err,
|
||||
)
|
||||
errors.Add(1)
|
||||
notify()
|
||||
continue
|
||||
}
|
||||
|
||||
if err := o.writer.WriteChapter(job.slug, chapter); err != nil {
|
||||
if err := o.store.WriteChapter(ctx, job.slug, chapter); err != nil {
|
||||
o.log.Error("chapter write failed",
|
||||
"book", job.slug,
|
||||
"chapter", job.ref.Number,
|
||||
"err", err,
|
||||
)
|
||||
errors.Add(1)
|
||||
notify()
|
||||
continue
|
||||
}
|
||||
|
||||
chaptersScraped.Add(1)
|
||||
notify()
|
||||
o.log.Info("chapter saved",
|
||||
"book", job.slug,
|
||||
"chapter", job.ref.Number,
|
||||
@@ -132,21 +184,27 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
meta, err := o.novel.ScrapeMetadata(ctx, bookURL)
|
||||
if err != nil {
|
||||
o.log.Error("metadata scrape failed", "url", bookURL, "err", err)
|
||||
errors.Add(1)
|
||||
notify()
|
||||
return
|
||||
}
|
||||
|
||||
// Persist / update metadata.yaml.
|
||||
if err := o.writer.WriteMetadata(meta); err != nil {
|
||||
// Persist / update metadata.
|
||||
if err := o.store.WriteMetadata(ctx, meta); err != nil {
|
||||
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
|
||||
// Continue — chapters can still be scraped.
|
||||
}
|
||||
|
||||
booksFound.Add(1)
|
||||
notify()
|
||||
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
||||
|
||||
// Fetch chapter list.
|
||||
refs, err := o.novel.ScrapeChapterList(ctx, bookURL)
|
||||
if err != nil {
|
||||
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
|
||||
errors.Add(1)
|
||||
notify()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,6 +212,15 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
|
||||
// Enqueue chapter jobs.
|
||||
for _, ref := range refs {
|
||||
// Apply chapter range filter (only in single-book mode when set).
|
||||
if o.cfg.FromChapter > 0 && ref.Number < o.cfg.FromChapter {
|
||||
chaptersSkipped.Add(1)
|
||||
continue
|
||||
}
|
||||
if o.cfg.ToChapter > 0 && ref.Number > o.cfg.ToChapter {
|
||||
chaptersSkipped.Add(1)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -174,14 +241,17 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
go func() {
|
||||
for err := range catErrs {
|
||||
o.log.Error("catalogue error", "err", err)
|
||||
errors.Add(1)
|
||||
notify()
|
||||
}
|
||||
}()
|
||||
|
||||
var bookWG sync.WaitGroup
|
||||
bookLoop:
|
||||
for entry := range entries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
break bookLoop
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -204,6 +274,9 @@ func (o *Orchestrator) Run(ctx context.Context) error {
|
||||
// Wait for all in-flight chapter scrapes to finish.
|
||||
chapterWG.Wait()
|
||||
|
||||
// Final progress notification.
|
||||
notify()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("orchestrator: context cancelled: %w", ctx.Err())
|
||||
}
|
||||
|
||||
317
scraper/internal/orchestrator/orchestrator_test.go
Normal file
317
scraper/internal/orchestrator/orchestrator_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// ── mock NovelScraper ─────────────────────────────────────────────────────────
|
||||
|
||||
type mockScraper struct {
|
||||
catalogue []scraper.CatalogueEntry
|
||||
meta scraper.BookMeta
|
||||
metaErr error
|
||||
chapters []scraper.ChapterRef
|
||||
chapterTextFn func(ref scraper.ChapterRef) (scraper.Chapter, error)
|
||||
}
|
||||
|
||||
func (m *mockScraper) SourceName() string { return "mock" }
|
||||
|
||||
func (m *mockScraper) ScrapeCatalogue(_ context.Context) (<-chan scraper.CatalogueEntry, <-chan error) {
|
||||
entries := make(chan scraper.CatalogueEntry, len(m.catalogue))
|
||||
errs := make(chan error, 1)
|
||||
for _, e := range m.catalogue {
|
||||
entries <- e
|
||||
}
|
||||
close(entries)
|
||||
close(errs)
|
||||
return entries, errs
|
||||
}
|
||||
|
||||
func (m *mockScraper) ScrapeMetadata(_ context.Context, _ string) (scraper.BookMeta, error) {
|
||||
return m.meta, m.metaErr
|
||||
}
|
||||
|
||||
func (m *mockScraper) ScrapeChapterList(_ context.Context, _ string) ([]scraper.ChapterRef, error) {
|
||||
return m.chapters, nil
|
||||
}
|
||||
|
||||
func (m *mockScraper) ScrapeChapterText(_ context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) {
|
||||
if m.chapterTextFn != nil {
|
||||
return m.chapterTextFn(ref)
|
||||
}
|
||||
return scraper.Chapter{Ref: ref, Text: "stub text"}, nil
|
||||
}
|
||||
|
||||
func (m *mockScraper) ScrapeRanking(_ context.Context, _ int) (<-chan scraper.BookMeta, <-chan error) {
|
||||
ch := make(chan scraper.BookMeta)
|
||||
errs := make(chan error)
|
||||
close(ch)
|
||||
close(errs)
|
||||
return ch, errs
|
||||
}
|
||||
|
||||
// ── mock Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
// mockStore records which methods were called; only implements what the
|
||||
// orchestrator touches. All other methods panic so unexpected calls surface
|
||||
// as test failures rather than silent no-ops.
|
||||
type mockStore struct {
|
||||
mu sync.Mutex
|
||||
writtenMeta []scraper.BookMeta
|
||||
writtenChapters []scraper.Chapter
|
||||
existingSlugs map[string]map[int]bool // slug → chapterNum → exists
|
||||
}
|
||||
|
||||
func newMockStore() *mockStore {
|
||||
return &mockStore{existingSlugs: make(map[string]map[int]bool)}
|
||||
}
|
||||
|
||||
func (s *mockStore) ChapterExists(_ context.Context, slug string, ref scraper.ChapterRef) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if m, ok := s.existingSlugs[slug]; ok {
|
||||
return m[ref.Number]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *mockStore) WriteChapter(_ context.Context, slug string, ch scraper.Chapter) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.writtenChapters = append(s.writtenChapters, ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []scraper.ChapterRef) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStore) WriteMetadata(_ context.Context, meta scraper.BookMeta) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.writtenMeta = append(s.writtenMeta, meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unimplemented Store methods — panic so accidental calls surface immediately.
|
||||
func (s *mockStore) ReadMetadata(_ context.Context, _ string) (scraper.BookMeta, bool, error) {
|
||||
panic("ReadMetadata not expected")
|
||||
}
|
||||
func (s *mockStore) ListBooks(_ context.Context) ([]scraper.BookMeta, error) {
|
||||
panic("ListBooks not expected")
|
||||
}
|
||||
func (s *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) {
|
||||
panic("LocalSlugs not expected")
|
||||
}
|
||||
func (s *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 }
|
||||
func (s *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) {
|
||||
panic("ReadChapter not expected")
|
||||
}
|
||||
func (s *mockStore) ListChapters(_ context.Context, _ string) ([]storage.ChapterInfo, error) {
|
||||
panic("ListChapters not expected")
|
||||
}
|
||||
func (s *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (s *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) {
|
||||
panic("ReindexChapters not expected")
|
||||
}
|
||||
func (s *mockStore) WriteRankingItem(_ context.Context, _ storage.RankingItem) error { return nil }
|
||||
func (s *mockStore) ReadRankingItems(_ context.Context) ([]storage.RankingItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *mockStore) GetAudioCache(_ context.Context, _ string) (string, bool) { return "", false }
|
||||
func (s *mockStore) SetAudioCache(_ context.Context, _, _ string) error { return nil }
|
||||
func (s *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (s *mockStore) GetProgress(_ context.Context, _, _ string) (storage.ReadingProgress, bool) {
|
||||
return storage.ReadingProgress{}, false
|
||||
}
|
||||
func (s *mockStore) SetProgress(_ context.Context, _ string, _ storage.ReadingProgress) error {
|
||||
return nil
|
||||
}
|
||||
func (s *mockStore) AllProgress(_ context.Context, _ string) ([]storage.ReadingProgress, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil }
|
||||
func (s *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
|
||||
func (s *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
|
||||
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
func (s *mockStore) BrowseHTMLKey(_ string, _ int) string { return "" }
|
||||
func (s *mockStore) BrowseFilteredHTMLKey(_ string, _ int, _, _, _ string) string { return "" }
|
||||
func (s *mockStore) BrowseCoverKey(_, _ string) string { return "" }
|
||||
func (s *mockStore) SaveBrowseAsset(_ context.Context, _ string, _ []byte, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *mockStore) GetBrowseAsset(_ context.Context, _ string) ([]byte, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
func (s *mockStore) CreateScrapeTask(_ context.Context, _, _ string) (string, error) {
|
||||
return "task-id", nil
|
||||
}
|
||||
func (s *mockStore) UpdateScrapeTask(_ context.Context, _ string, _ storage.ScrapeTaskUpdate) error {
|
||||
return nil
|
||||
}
|
||||
func (s *mockStore) ListScrapeTasks(_ context.Context) ([]storage.ScrapeTask, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestRun_SingleBook verifies the happy-path single-book scrape: metadata is
|
||||
// persisted and all chapters are written to the store.
|
||||
func TestRun_SingleBook(t *testing.T) {
|
||||
novel := &mockScraper{
|
||||
meta: scraper.BookMeta{Slug: "the-iron-throne", Title: "The Iron Throne"},
|
||||
chapters: []scraper.ChapterRef{
|
||||
{Number: 1, Title: "Chapter 1", URL: "https://example.com/book/ch-1"},
|
||||
{Number: 2, Title: "Chapter 2", URL: "https://example.com/book/ch-2"},
|
||||
{Number: 3, Title: "Chapter 3", URL: "https://example.com/book/ch-3"},
|
||||
},
|
||||
}
|
||||
store := newMockStore()
|
||||
|
||||
o := New(Config{Workers: 2, SingleBookURL: "https://example.com/book/the-iron-throne"}, novel, discardLogger(), store)
|
||||
if err := o.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if len(store.writtenMeta) != 1 {
|
||||
t.Errorf("writtenMeta count = %d, want 1", len(store.writtenMeta))
|
||||
}
|
||||
if len(store.writtenChapters) != 3 {
|
||||
t.Errorf("writtenChapters count = %d, want 3", len(store.writtenChapters))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_SingleBook_SkipsExistingChapters verifies that chapters already in
|
||||
// the store are not re-scraped.
|
||||
func TestRun_SingleBook_SkipsExistingChapters(t *testing.T) {
|
||||
novel := &mockScraper{
|
||||
meta: scraper.BookMeta{Slug: "test-novel", Title: "Test Novel"},
|
||||
chapters: []scraper.ChapterRef{
|
||||
{Number: 1, Title: "Chapter 1"},
|
||||
{Number: 2, Title: "Chapter 2"},
|
||||
},
|
||||
}
|
||||
store := newMockStore()
|
||||
// Mark chapter 1 as already existing.
|
||||
store.existingSlugs["test-novel"] = map[int]bool{1: true}
|
||||
|
||||
o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/test-novel"}, novel, discardLogger(), store)
|
||||
if err := o.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
// Only chapter 2 should have been written; chapter 1 was skipped.
|
||||
if len(store.writtenChapters) != 1 {
|
||||
t.Errorf("writtenChapters count = %d, want 1 (skipped ch1)", len(store.writtenChapters))
|
||||
}
|
||||
if store.writtenChapters[0].Ref.Number != 2 {
|
||||
t.Errorf("expected chapter 2 to be written, got chapter %d", store.writtenChapters[0].Ref.Number)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_CatalogueMode verifies that catalogue mode processes all books.
|
||||
func TestRun_CatalogueMode(t *testing.T) {
|
||||
novel := &mockScraper{
|
||||
catalogue: []scraper.CatalogueEntry{
|
||||
{Title: "Book A", URL: "https://example.com/book/a"},
|
||||
{Title: "Book B", URL: "https://example.com/book/b"},
|
||||
},
|
||||
meta: scraper.BookMeta{Slug: "book-slug", Title: "A Book"},
|
||||
chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}},
|
||||
}
|
||||
store := newMockStore()
|
||||
|
||||
o := New(Config{Workers: 2}, novel, discardLogger(), store)
|
||||
if err := o.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
// 2 books → 2 metadata writes, 2 chapter writes (one chapter per book).
|
||||
if len(store.writtenMeta) != 2 {
|
||||
t.Errorf("writtenMeta count = %d, want 2", len(store.writtenMeta))
|
||||
}
|
||||
if len(store.writtenChapters) != 2 {
|
||||
t.Errorf("writtenChapters count = %d, want 2", len(store.writtenChapters))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_OnProgress_Called verifies that the OnProgress callback fires at
|
||||
// least once upon completion.
|
||||
func TestRun_OnProgress_Called(t *testing.T) {
|
||||
novel := &mockScraper{
|
||||
meta: scraper.BookMeta{Slug: "progress-book", Title: "Progress Book"},
|
||||
chapters: []scraper.ChapterRef{{Number: 1, Title: "Chapter 1"}},
|
||||
}
|
||||
store := newMockStore()
|
||||
|
||||
var callCount int
|
||||
o := New(Config{
|
||||
Workers: 1,
|
||||
SingleBookURL: "https://example.com/book/progress-book",
|
||||
OnProgress: func(_ Progress) {
|
||||
callCount++
|
||||
},
|
||||
}, novel, discardLogger(), store)
|
||||
|
||||
if err := o.Run(context.Background()); err != nil {
|
||||
t.Fatalf("Run() returned error: %v", err)
|
||||
}
|
||||
if callCount == 0 {
|
||||
t.Error("OnProgress was never called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ContextCancelled verifies that Run returns a non-nil error when the
|
||||
// context is cancelled before work completes.
|
||||
func TestRun_ContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
novel := &mockScraper{
|
||||
meta: scraper.BookMeta{Slug: "cancel-book", Title: "Cancel Book"},
|
||||
chapters: []scraper.ChapterRef{{Number: 1}},
|
||||
}
|
||||
store := newMockStore()
|
||||
|
||||
o := New(Config{Workers: 1, SingleBookURL: "https://example.com/book/cancel-book"}, novel, discardLogger(), store)
|
||||
err := o.Run(ctx)
|
||||
if err == nil {
|
||||
t.Error("expected non-nil error when context is cancelled, got nil")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package htmlutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +11,24 @@ import (
|
||||
"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 using standard
|
||||
// URL resolution (handles relative paths, absolute paths, etc.).
|
||||
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))
|
||||
@@ -48,8 +67,8 @@ matched:
|
||||
return true
|
||||
}
|
||||
|
||||
// attrVal returns the value of attribute key from node n.
|
||||
func attrVal(n *html.Node, key string) string {
|
||||
// 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
|
||||
@@ -58,8 +77,11 @@ func attrVal(n *html.Node, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// textContent returns the concatenated text content of all descendant text nodes.
|
||||
func textContent(n *html.Node) string {
|
||||
// attrVal is an unexported alias kept for internal use within this package.
|
||||
func attrVal(n *html.Node, key string) string { return AttrVal(n, key) }
|
||||
|
||||
// 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) {
|
||||
@@ -74,6 +96,9 @@ func textContent(n *html.Node) string {
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// textContent is an unexported alias kept for internal use within this package.
|
||||
func textContent(n *html.Node) string { return TextContent(n) }
|
||||
|
||||
// FindFirst returns the first node matching sel within root.
|
||||
func FindFirst(root *html.Node, sel scraper.Selector) *html.Node {
|
||||
var found *html.Node
|
||||
|
||||
221
scraper/internal/scraper/htmlutil/htmlutil_test.go
Normal file
221
scraper/internal/scraper/htmlutil/htmlutil_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package htmlutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
// ── ResolveURL ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestResolveURL(t *testing.T) {
|
||||
cases := []struct{ base, href, want string }{
|
||||
// Already absolute → unchanged.
|
||||
{"https://example.com", "https://other.com/page", "https://other.com/page"},
|
||||
{"https://example.com", "http://other.com/page", "http://other.com/page"},
|
||||
// Absolute path.
|
||||
{"https://example.com", "/book/slug", "https://example.com/book/slug"},
|
||||
// Relative path.
|
||||
{"https://example.com/genre/all", "page?p=2", "https://example.com/genre/page?p=2"},
|
||||
// Empty href → base itself.
|
||||
{"https://example.com", "", "https://example.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ResolveURL(c.base, c.href)
|
||||
if got != c.want {
|
||||
t.Errorf("ResolveURL(%q, %q) = %q, want %q", c.base, c.href, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── AttrVal ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAttrVal(t *testing.T) {
|
||||
root, err := ParseHTML(`<html><body><a href="/book/slug" class="link">text</a></body></html>`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := FindFirst(root, scraper.Selector{Tag: "a"})
|
||||
if a == nil {
|
||||
t.Fatal("expected to find <a>")
|
||||
}
|
||||
if got := AttrVal(a, "href"); got != "/book/slug" {
|
||||
t.Errorf("AttrVal href = %q, want %q", got, "/book/slug")
|
||||
}
|
||||
if got := AttrVal(a, "class"); got != "link" {
|
||||
t.Errorf("AttrVal class = %q, want %q", got, "link")
|
||||
}
|
||||
if got := AttrVal(a, "missing"); got != "" {
|
||||
t.Errorf("AttrVal missing = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── TextContent ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestTextContent(t *testing.T) {
|
||||
root, err := ParseHTML(`<html><body><p>Hello <b>world</b></p></body></html>`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := FindFirst(root, scraper.Selector{Tag: "p"})
|
||||
if p == nil {
|
||||
t.Fatal("expected to find <p>")
|
||||
}
|
||||
if got := TextContent(p); got != "Hello world" {
|
||||
t.Errorf("TextContent = %q, want %q", got, "Hello world")
|
||||
}
|
||||
}
|
||||
|
||||
// ── FindFirst / FindAll ───────────────────────────────────────────────────────
|
||||
|
||||
func TestFindFirst_ByTag(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><h1>Title</h1><h2>Sub</h2></body></html>`)
|
||||
n := FindFirst(root, scraper.Selector{Tag: "h1"})
|
||||
if n == nil {
|
||||
t.Fatal("expected to find <h1>")
|
||||
}
|
||||
if TextContent(n) != "Title" {
|
||||
t.Errorf("h1 text = %q, want %q", TextContent(n), "Title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFirst_ByClass(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><span class="author foo">JR</span></body></html>`)
|
||||
n := FindFirst(root, scraper.Selector{Tag: "span", Class: "author"})
|
||||
if n == nil {
|
||||
t.Fatal("expected to find span.author")
|
||||
}
|
||||
if TextContent(n) != "JR" {
|
||||
t.Errorf("author text = %q, want %q", TextContent(n), "JR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFirst_ByID(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><div id="content"><p>text</p></div></body></html>`)
|
||||
n := FindFirst(root, scraper.Selector{ID: "content"})
|
||||
if n == nil {
|
||||
t.Fatal("expected to find #content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFirst_NoMatch(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><p>nothing</p></body></html>`)
|
||||
n := FindFirst(root, scraper.Selector{Tag: "h1"})
|
||||
if n != nil {
|
||||
t.Errorf("expected nil for missing tag, got %v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAll_Multiple(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body>
|
||||
<li class="novel-item">A</li>
|
||||
<li class="novel-item">B</li>
|
||||
<li class="other">C</li>
|
||||
</body></html>`)
|
||||
nodes := FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item"})
|
||||
if len(nodes) != 2 {
|
||||
t.Errorf("FindAll novel-item = %d, want 2", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ExtractFirst / ExtractAll ─────────────────────────────────────────────────
|
||||
|
||||
func TestExtractFirst_TextNode(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><h1 class="novel-title">Shadow Slave</h1></body></html>`)
|
||||
got := ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"})
|
||||
if got != "Shadow Slave" {
|
||||
t.Errorf("ExtractFirst title = %q, want %q", got, "Shadow Slave")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFirst_AttrNode(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><img src="/covers/slug.jpg"></body></html>`)
|
||||
got := ExtractFirst(root, scraper.Selector{Tag: "img", Attr: "src"})
|
||||
if got != "/covers/slug.jpg" {
|
||||
t.Errorf("ExtractFirst img src = %q, want %q", got, "/covers/slug.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFirst_Missing(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body></body></html>`)
|
||||
got := ExtractFirst(root, scraper.Selector{Tag: "h1"})
|
||||
if got != "" {
|
||||
t.Errorf("ExtractFirst missing = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAll_Genres(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body>
|
||||
<div class="genres">
|
||||
<a href="/genre/action">Action</a>
|
||||
<a href="/genre/fantasy">Fantasy</a>
|
||||
</div>
|
||||
</body></html>`)
|
||||
genresNode := FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
|
||||
if genresNode == nil {
|
||||
t.Fatal("expected genres div")
|
||||
}
|
||||
genres := ExtractAll(genresNode, scraper.Selector{Tag: "a"})
|
||||
if len(genres) != 2 {
|
||||
t.Fatalf("genres = %v, want 2", genres)
|
||||
}
|
||||
if genres[0] != "Action" || genres[1] != "Fantasy" {
|
||||
t.Errorf("genres = %v, want [Action Fantasy]", genres)
|
||||
}
|
||||
}
|
||||
|
||||
// ── NodeToMarkdown ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestNodeToMarkdown_Paragraphs(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><div id="content">
|
||||
<p>First paragraph.</p>
|
||||
<p>Second paragraph.</p>
|
||||
</div></body></html>`)
|
||||
container := FindFirst(root, scraper.Selector{ID: "content"})
|
||||
if container == nil {
|
||||
t.Fatal("missing #content")
|
||||
}
|
||||
md := NodeToMarkdown(container)
|
||||
if md == "" {
|
||||
t.Fatal("NodeToMarkdown returned empty string")
|
||||
}
|
||||
for _, want := range []string{"First paragraph", "Second paragraph"} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("NodeToMarkdown missing %q in:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeToMarkdown_Bold(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><div id="content"><p>He was <strong>very</strong> strong.</p></div></body></html>`)
|
||||
container := FindFirst(root, scraper.Selector{ID: "content"})
|
||||
md := NodeToMarkdown(container)
|
||||
if !strings.Contains(md, "**very**") {
|
||||
t.Errorf("NodeToMarkdown should wrap <strong> in **, got:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeToMarkdown_ScriptStripped(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><div id="content"><p>Good</p><script>alert(1)</script></div></body></html>`)
|
||||
container := FindFirst(root, scraper.Selector{ID: "content"})
|
||||
md := NodeToMarkdown(container)
|
||||
if strings.Contains(md, "alert") {
|
||||
t.Errorf("NodeToMarkdown should strip <script> content, got:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeToMarkdown_CollapseBlankLines(t *testing.T) {
|
||||
root, _ := ParseHTML(`<html><body><div id="content">
|
||||
<p>A</p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p>B</p>
|
||||
</div></body></html>`)
|
||||
container := FindFirst(root, scraper.Selector{ID: "content"})
|
||||
md := NodeToMarkdown(container)
|
||||
// Should not have more than one consecutive blank line.
|
||||
if strings.Contains(md, "\n\n\n") {
|
||||
t.Errorf("NodeToMarkdown should collapse triple newlines, got:\n%q", md)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
// wires them together without knowing anything about the concrete provider.
|
||||
package scraper
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Domain types ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -58,6 +61,19 @@ type Chapter struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ─── Scraping selector descriptors ───────────────────────────────────────────
|
||||
|
||||
// Selector describes how to locate an element in an HTML document.
|
||||
@@ -120,16 +136,6 @@ type RankingProvider interface {
|
||||
ScrapeRanking(ctx context.Context, maxPages int) (<-chan BookMeta, <-chan error)
|
||||
}
|
||||
|
||||
// RankingPageCacher persists and retrieves raw HTML for individual ranking pages.
|
||||
// Implementations (e.g. writer.Writer) store files on disk so that a
|
||||
// subsequent ScrapeRanking call can serve cached HTML without a network round-trip.
|
||||
type RankingPageCacher interface {
|
||||
// WriteRankingPageCache stores the raw HTML string for the given page number.
|
||||
WriteRankingPageCache(page int, html string) error
|
||||
// ReadRankingPageCache returns the cached HTML for page, or ("", nil) on a miss.
|
||||
ReadRankingPageCache(page int) (string, error)
|
||||
}
|
||||
|
||||
// NovelScraper is the full interface that a concrete novel source must implement.
|
||||
// It composes all four provider interfaces.
|
||||
type NovelScraper interface {
|
||||
|
||||
541
scraper/internal/server/handlers_audio.go
Normal file
541
scraper/internal/server/handlers_audio.go
Normal file
@@ -0,0 +1,541 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
|
||||
//
|
||||
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
||||
//
|
||||
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
|
||||
// Kokoro generates the audio, saves it to its own temp storage, and returns
|
||||
// the download filename in the X-Download-Path response header.
|
||||
// We cache that filename (in memory, keyed by slug/chapter/voice) and
|
||||
// return a proxy URL that the browser sets as audio.src.
|
||||
//
|
||||
// TTS is always generated at speed 1.0; playback speed is controlled
|
||||
// client-side via the <audio> element's playbackRate.
|
||||
//
|
||||
// On a cache hit the proxy URL is returned immediately without re-generating.
|
||||
// Concurrent requests for the same key are deduplicated.
|
||||
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional voice from JSON body. Speed is intentionally ignored —
|
||||
// TTS is always generated at 1.0; playback speed is applied client-side.
|
||||
voice := s.kokoroVoice
|
||||
var body struct {
|
||||
Voice string `json:"voice"`
|
||||
MaxChars int `json:"max_chars"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
if body.Voice != "" {
|
||||
voice = body.Voice
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
||||
|
||||
// Fast path: already generated (check persistent store first).
|
||||
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
||||
s.writeAudioResponse(w, slug, n, voice, filename)
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate concurrent generation for the same key.
|
||||
s.audioMu.Lock()
|
||||
if ch, ok := s.audioInFlight[cacheKey]; ok {
|
||||
s.audioMu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-r.Context().Done():
|
||||
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// Check store again after waiting.
|
||||
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
||||
s.writeAudioResponse(w, slug, n, voice, filename)
|
||||
} else {
|
||||
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
s.audioInFlight[cacheKey] = ch
|
||||
s.audioMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.audioMu.Lock()
|
||||
delete(s.audioInFlight, cacheKey)
|
||||
s.audioMu.Unlock()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// Load and validate chapter text.
|
||||
raw, err := s.store.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
text := stripMarkdown(raw)
|
||||
if text == "" {
|
||||
http.Error(w, `{"error":"chapter text is empty"}`, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if body.MaxChars > 0 && len([]rune(text)) > body.MaxChars {
|
||||
text = string([]rune(text)[:body.MaxChars])
|
||||
}
|
||||
if s.kokoroURL == "" {
|
||||
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Call Kokoro POST /v1/audio/speech at speed 1.0.
|
||||
// Kokoro saves the generated audio to its own temp storage and returns the
|
||||
// download path in the X-Download-Path response header.
|
||||
filename, err := s.generateSpeech(r.Context(), text, voice, 1.0)
|
||||
if err != nil {
|
||||
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
|
||||
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SetAudioCache(r.Context(), cacheKey, filename); err != nil {
|
||||
s.log.Warn("audio cache write failed", "slug", slug, "chapter", n, "cache_key", cacheKey, "err", err)
|
||||
}
|
||||
|
||||
// Download generated audio from Kokoro and persist to MinIO synchronously
|
||||
// so that the presigned URL returned to the client is immediately valid.
|
||||
minioKey := s.store.AudioObjectKey(slug, n, voice)
|
||||
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
||||
if dlErr != nil {
|
||||
s.log.Warn("audio MinIO upload skipped: kokoro download failed",
|
||||
"slug", slug, "chapter", n, "filename", filename, "err", dlErr)
|
||||
} else if putErr := s.store.PutAudio(r.Context(), minioKey, audioData); putErr != nil {
|
||||
s.log.Warn("audio MinIO upload failed",
|
||||
"slug", slug, "chapter", n, "key", minioKey, "err", putErr)
|
||||
// upload failure is non-fatal; the client can still stream via Kokoro proxy
|
||||
} else {
|
||||
s.log.Info("audio uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
|
||||
}
|
||||
|
||||
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
|
||||
s.writeAudioResponse(w, slug, n, voice, filename)
|
||||
}
|
||||
|
||||
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
|
||||
// and returns the filename from the X-Download-Path response header.
|
||||
func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "mp3",
|
||||
"speed": speed,
|
||||
"stream": false,
|
||||
"return_download_link": true,
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kokoro request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Drain body so the connection can be reused.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("kokoro status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// X-Download-Path is e.g. "/download/speech_abc123.mp3"
|
||||
dlPath := resp.Header.Get("X-Download-Path")
|
||||
if dlPath == "" {
|
||||
return "", fmt.Errorf("kokoro did not return X-Download-Path header")
|
||||
}
|
||||
|
||||
// Extract just the filename from the path.
|
||||
filename := dlPath
|
||||
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
|
||||
filename = dlPath[idx+1:]
|
||||
}
|
||||
if filename == "" {
|
||||
return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// downloadFromKokoro downloads a generated audio file from Kokoro's temp storage
|
||||
// using GET /v1/download/{filename} and returns the raw bytes.
|
||||
func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byte, error) {
|
||||
url := s.kokoroURL + "/v1/download/" + filename
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build download request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro download request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kokoro download status %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read kokoro download body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// writeAudioResponse writes the JSON response for a generated audio chapter.
|
||||
// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}.
|
||||
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, filename string) {
|
||||
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"url": proxyURL,
|
||||
"filename": filename,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
||||
// It looks up the Kokoro download filename for this chapter (voice) and
|
||||
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
||||
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.kokoroVoice
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
||||
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
|
||||
if !ok {
|
||||
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
kokoroURL := s.kokoroURL + "/v1/download/" + filename
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build proxy request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "kokoro download failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||
w.Header().Set("Content-Length", cl)
|
||||
}
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ─── Presigned URL handlers ───────────────────────────────────────────────────
|
||||
|
||||
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
|
||||
// Returns a short-lived presigned MinIO URL for the chapter markdown object.
|
||||
// The SvelteKit server uses this to fetch chapter content server-side.
|
||||
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
url, err := s.store.PresignChapter(r.Context(), slug, n, 15*time.Minute)
|
||||
if err != nil {
|
||||
s.log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
|
||||
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
|
||||
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
|
||||
// Returns a presigned MinIO URL for the audio object (if it has been generated).
|
||||
// Query params: voice (optional, defaults to server default).
|
||||
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.kokoroVoice
|
||||
}
|
||||
|
||||
key := s.store.AudioObjectKey(slug, n, voice)
|
||||
|
||||
// Return 404 when the object hasn't been uploaded yet — the client treats
|
||||
// this as "audio not ready" and will either poll or trigger generation.
|
||||
if !s.store.AudioExists(r.Context(), key) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
|
||||
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
|
||||
// ─── Voices API ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleVoices handles GET /api/voices.
|
||||
// Returns the list of available Kokoro voices as JSON: {"voices": [...]}
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, _ *http.Request) {
|
||||
voices := s.voices()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"voices": voices})
|
||||
}
|
||||
|
||||
// ─── Voice sample generation ──────────────────────────────────────────────────
|
||||
|
||||
// voiceSampleText is the short passage used for voice sample previews.
|
||||
const voiceSampleText = "The ancient library held secrets older than memory itself, its dust-laden shelves stretching upward into shadow. She reached for the worn leather spine, fingers trembling with anticipation."
|
||||
|
||||
// voiceSampleKey returns the MinIO object key for a voice sample.
|
||||
// Key: _voice-samples/{voice}.mp3
|
||||
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)
|
||||
}
|
||||
|
||||
// warmVoiceSamples runs at startup in a background goroutine.
|
||||
// It generates a short audio sample for every available Kokoro voice that
|
||||
// doesn't already have one in MinIO, so the UI voice selector has playable
|
||||
// previews without requiring a manual trigger.
|
||||
// It respects ctx cancellation and waits up to 30 s for Kokoro to become
|
||||
// reachable before giving up.
|
||||
func (s *Server) warmVoiceSamples(ctx context.Context) {
|
||||
if s.kokoroURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for Kokoro to be reachable (it may still be starting up).
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
voices := s.voices()
|
||||
s.log.Info("warming voice samples", "voices", len(voices))
|
||||
|
||||
generated, skipped, failed := 0, 0, 0
|
||||
for _, voice := range voices {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
key := voiceSampleKey(voice)
|
||||
if s.store.AudioExists(ctx, key) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
filename, err := s.generateSpeech(ctx, voiceSampleText, voice, 1.0)
|
||||
if err != nil {
|
||||
s.log.Warn("voice sample warmup: generation failed", "voice", voice, "err", err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
audioData, err := s.downloadFromKokoro(ctx, filename)
|
||||
if err != nil {
|
||||
s.log.Warn("voice sample warmup: download failed", "voice", voice, "err", err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.store.PutAudio(ctx, key, audioData); err != nil {
|
||||
s.log.Warn("voice sample warmup: upload failed", "voice", voice, "key", key, "err", err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
s.log.Debug("voice sample warmed", "voice", voice)
|
||||
generated++
|
||||
}
|
||||
|
||||
s.log.Info("voice sample warmup complete",
|
||||
"generated", generated, "skipped", skipped, "failed", failed)
|
||||
}
|
||||
|
||||
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
|
||||
// It generates short audio samples for each available voice and stores them
|
||||
// in the audio MinIO bucket so the UI can play them during voice selection.
|
||||
// Already-generated samples are skipped (idempotent).
|
||||
// Optional JSON body: {"voices": ["af_bella", ...]} to generate a subset.
|
||||
// Returns: {"generated": [...], "skipped": [...], "failed": [...]}
|
||||
func (s *Server) handleGenerateVoiceSamples(w http.ResponseWriter, r *http.Request) {
|
||||
if s.kokoroURL == "" {
|
||||
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional voice list from body.
|
||||
var body struct {
|
||||
Voices []string `json:"voices"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
targetVoices := body.Voices
|
||||
if len(targetVoices) == 0 {
|
||||
targetVoices = s.voices()
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Generated []string `json:"generated"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
var res result
|
||||
|
||||
for _, voice := range targetVoices {
|
||||
key := voiceSampleKey(voice)
|
||||
|
||||
// Skip if already uploaded.
|
||||
if s.store.AudioExists(r.Context(), key) {
|
||||
res.Skipped = append(res.Skipped, voice)
|
||||
s.log.Debug("voice sample already exists, skipping", "voice", voice)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate via Kokoro (speed 1.0 for samples).
|
||||
filename, err := s.generateSpeech(r.Context(), voiceSampleText, voice, 1.0)
|
||||
if err != nil {
|
||||
s.log.Warn("voice sample generation failed", "voice", voice, "err", err)
|
||||
res.Failed = append(res.Failed, voice)
|
||||
continue
|
||||
}
|
||||
|
||||
// Download from Kokoro and upload to MinIO.
|
||||
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
||||
if dlErr != nil {
|
||||
s.log.Warn("voice sample kokoro download failed", "voice", voice, "err", dlErr)
|
||||
res.Failed = append(res.Failed, voice)
|
||||
continue
|
||||
}
|
||||
|
||||
if putErr := s.store.PutAudio(r.Context(), key, audioData); putErr != nil {
|
||||
s.log.Warn("voice sample MinIO upload failed", "voice", voice, "key", key, "err", putErr)
|
||||
res.Failed = append(res.Failed, voice)
|
||||
continue
|
||||
}
|
||||
|
||||
s.log.Info("voice sample generated", "voice", voice, "key", key)
|
||||
res.Generated = append(res.Generated, voice)
|
||||
}
|
||||
|
||||
if res.Generated == nil {
|
||||
res.Generated = []string{}
|
||||
}
|
||||
if res.Skipped == nil {
|
||||
res.Skipped = []string{}
|
||||
}
|
||||
if res.Failed == nil {
|
||||
res.Failed = []string{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
||||
// Returns a presigned URL for the voice sample audio file stored in MinIO.
|
||||
// Returns 404 if the sample has not been generated yet.
|
||||
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
||||
voice := r.PathValue("voice")
|
||||
if voice == "" {
|
||||
http.Error(w, `{"error":"missing voice"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key := voiceSampleKey(voice)
|
||||
|
||||
if !s.store.AudioExists(r.Context(), key) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.log.Error("presign voice sample failed", "voice", voice, "err", err)
|
||||
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
575
scraper/internal/server/handlers_browse.go
Normal file
575
scraper/internal/server/handlers_browse.go
Normal file
@@ -0,0 +1,575 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper/htmlutil"
|
||||
)
|
||||
|
||||
// ─── Browse API ───────────────────────────────────────────────────────────────
|
||||
|
||||
// NovelListing represents a single novel entry from the novelfire browse page.
|
||||
type NovelListing struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
Rank string `json:"rank"`
|
||||
Rating string `json:"rating"`
|
||||
Chapters string `json:"chapters"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
const novelFireBase = "https://novelfire.net"
|
||||
const novelFireDomain = "novelfire.net"
|
||||
|
||||
// handleBrowse handles GET /api/browse.
|
||||
// Query params:
|
||||
//
|
||||
// page (default 1)
|
||||
// genre (default "all")
|
||||
// sort (default "popular")
|
||||
// status (default "all")
|
||||
// type (default "all-novel")
|
||||
//
|
||||
// Returns JSON: {"novels":[...], "page": N, "hasNext": bool}
|
||||
//
|
||||
// Cache strategy: check MinIO browse bucket first (key: {domain}/html/page-N.html);
|
||||
// if a snapshot exists, parse it and return structured JSON.
|
||||
// On a cache miss, fetch live from novelfire.net, return the result, and
|
||||
// trigger a background SingleFile snapshot + ranking population.
|
||||
func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := q.Get("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
genre := q.Get("genre")
|
||||
if genre == "" {
|
||||
genre = "all"
|
||||
}
|
||||
sortBy := q.Get("sort")
|
||||
if sortBy == "" {
|
||||
sortBy = "popular"
|
||||
}
|
||||
status := q.Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
novelType := q.Get("type")
|
||||
if novelType == "" {
|
||||
novelType = "all-novel"
|
||||
}
|
||||
|
||||
pageNum, _ := strconv.Atoi(page)
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// ── Cache-first: try MinIO snapshot (new key layout) ─────────────────
|
||||
cacheKey := s.store.BrowseFilteredHTMLKey(novelFireDomain, pageNum, sortBy, genre, status)
|
||||
if html, ok, err := s.store.GetBrowsePage(ctx, cacheKey); err == nil && ok && len(html) > 0 {
|
||||
novels, hasNext := parseBrowsePage(strings.NewReader(html))
|
||||
s.log.Debug("browse: served from cache", "key", cacheKey)
|
||||
// Still fire background ranking population in case PocketBase ranking
|
||||
// records are missing (e.g. after a schema reset / fresh deploy).
|
||||
targetURLForRanking := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%s",
|
||||
novelFireBase, genre, sortBy, status, novelType, page)
|
||||
s.triggerDirectScrape(cacheKey, targetURLForRanking)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"novels": novels,
|
||||
"page": pageNum,
|
||||
"hasNext": hasNext,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Live fallback: direct fetch from novelfire.net ───────────────────
|
||||
// Build URL: /genre-{genre}/sort-{sort}/status-{status}/{type}?page={page}
|
||||
targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%s",
|
||||
novelFireBase, genre, sortBy, status, novelType, page)
|
||||
|
||||
var novels []NovelListing
|
||||
var hasNext bool
|
||||
var fetchErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
req, fetchErr = http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if fetchErr != nil {
|
||||
http.Error(w, `{"error":"failed to build request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
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")
|
||||
// Do NOT set Accept-Encoding manually: Go's http.Transport handles
|
||||
// transparent gzip decompression only when it adds the header itself.
|
||||
// If we set it explicitly, Transport disables auto-decompression and
|
||||
// parseBrowsePage receives raw gzip bytes instead of HTML.
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fetchErr = err
|
||||
s.log.Warn("browse fetch failed, retrying", "url", targetURL, "attempt", attempt, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
fetchErr = fmt.Errorf("upstream returned %d", resp.StatusCode)
|
||||
s.log.Warn("browse upstream error, retrying", "url", targetURL, "attempt", attempt, "status", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
novels, hasNext = parseBrowsePage(resp.Body)
|
||||
resp.Body.Close()
|
||||
fetchErr = nil
|
||||
break
|
||||
}
|
||||
if fetchErr != nil {
|
||||
s.log.Error("browse fetch failed after retries", "url", targetURL, "err", fetchErr)
|
||||
// ── In-memory fallback: use cached result from a prior successful fetch ──
|
||||
s.browseMemCacheMu.RLock()
|
||||
entry, memHit := s.browseMemCache[cacheKey]
|
||||
s.browseMemCacheMu.RUnlock()
|
||||
if memHit {
|
||||
s.log.Warn("browse: upstream unavailable, serving stale in-memory cache",
|
||||
"key", cacheKey, "age", time.Since(entry.cachedAt).Round(time.Second))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"novels": entry.novels,
|
||||
"page": pageNum,
|
||||
"hasNext": entry.hasNext,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, fetchErr.Error()), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Populate in-memory cache with the fresh upstream result ──────────
|
||||
if len(novels) > 0 {
|
||||
s.browseMemCacheMu.Lock()
|
||||
s.browseMemCache[cacheKey] = browseCacheEntry{
|
||||
novels: novels,
|
||||
hasNext: hasNext,
|
||||
cachedAt: time.Now(),
|
||||
}
|
||||
s.browseMemCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// ── Background: fetch and cache page directly from novelfire.net ─────
|
||||
// Fire-and-forget: stores raw HTML in MinIO and populates the ranking
|
||||
// collection in PocketBase (no browser/SingleFile needed).
|
||||
s.triggerDirectScrape(cacheKey, targetURL)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"novels": novels,
|
||||
"page": pageNum,
|
||||
"hasNext": hasNext,
|
||||
})
|
||||
}
|
||||
|
||||
// triggerDirectScrape fires a background goroutine that:
|
||||
// 1. Fetches pageURL directly from novelfire.net using Go's HTTP client
|
||||
// (no browser/SingleFile needed — the page is server-rendered HTML).
|
||||
// 2. Stores the raw HTML in MinIO at cacheKey so future requests are served
|
||||
// from cache without hitting the origin.
|
||||
// 3. Parses the HTML to extract novel listings.
|
||||
// 4. For each listing, upserts a ranking record in PocketBase (rank, slug,
|
||||
// title, cover key, source_url).
|
||||
// 5. Fires a separate goroutine per cover image to download and store it at
|
||||
// {domain}/assets/book-covers/{slug}.jpg in MinIO.
|
||||
//
|
||||
// It is a no-op when a refresh for this cache key is already in progress.
|
||||
// The goroutine uses a fresh context so it outlives the HTTP request.
|
||||
func (s *Server) triggerDirectScrape(cacheKey, pageURL string) {
|
||||
s.browseMu.Lock()
|
||||
if _, inflight := s.browseInFlight[cacheKey]; inflight {
|
||||
s.browseMu.Unlock()
|
||||
return
|
||||
}
|
||||
s.browseInFlight[cacheKey] = struct{}{}
|
||||
s.browseMu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.browseMu.Lock()
|
||||
delete(s.browseInFlight, cacheKey)
|
||||
s.browseMu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
s.log.Warn("triggerDirectScrape: build request failed", "key", cacheKey, "err", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.log.Warn("triggerDirectScrape: fetch failed", "key", cacheKey, "err", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
s.log.Warn("triggerDirectScrape: non-200 response", "key", cacheKey, "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
htmlBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
s.log.Warn("triggerDirectScrape: read body failed", "key", cacheKey, "err", readErr)
|
||||
return
|
||||
}
|
||||
if len(htmlBytes) == 0 {
|
||||
s.log.Warn("triggerDirectScrape: empty response body", "key", cacheKey)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the HTML in MinIO so subsequent requests are cache-hits.
|
||||
if putErr := s.store.SaveBrowsePage(ctx, cacheKey, string(htmlBytes)); putErr != nil {
|
||||
s.log.Warn("triggerDirectScrape: SaveBrowsePage failed", "key", cacheKey, "err", putErr)
|
||||
// Non-fatal: continue to populate PocketBase/covers even if MinIO write fails.
|
||||
} else {
|
||||
s.log.Info("triggerDirectScrape: cached browse page", "key", cacheKey, "bytes", len(htmlBytes))
|
||||
}
|
||||
|
||||
// Parse to extract novel listings.
|
||||
novels, _ := parseBrowsePage(strings.NewReader(string(htmlBytes)))
|
||||
if len(novels) == 0 {
|
||||
s.log.Warn("triggerDirectScrape: no novels parsed", "key", cacheKey)
|
||||
return
|
||||
}
|
||||
|
||||
// Upsert each novel into PocketBase ranking and kick off cover downloads.
|
||||
for i, novel := range novels {
|
||||
rank := i + 1
|
||||
coverKey := s.store.BrowseCoverKey(novelFireDomain, novel.Slug)
|
||||
|
||||
item := storage.RankingItem{
|
||||
Rank: rank,
|
||||
Slug: novel.Slug,
|
||||
Title: novel.Title,
|
||||
Cover: coverKey, // stored as MinIO key; UI fetches via /api/cover/...
|
||||
SourceURL: novel.URL,
|
||||
}
|
||||
if werr := s.store.WriteRankingItem(ctx, item); werr != nil {
|
||||
s.log.Warn("triggerDirectScrape: WriteRankingItem failed",
|
||||
"slug", novel.Slug, "err", werr)
|
||||
}
|
||||
|
||||
if novel.Cover != "" {
|
||||
go s.downloadAndStoreCover(coverKey, novel.Cover)
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("triggerDirectScrape: ranking populated", "count", len(novels), "key", cacheKey)
|
||||
}()
|
||||
}
|
||||
|
||||
// warmBrowseCache checks whether the browse cache for page 1 is populated in
|
||||
// MinIO and, if not, triggers a background direct scrape. This is called
|
||||
// once on server startup so the first user request is likely served from cache.
|
||||
func (s *Server) warmBrowseCache() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cacheKey := s.store.BrowseHTMLKey(novelFireDomain, 1)
|
||||
if _, ok, err := s.store.GetBrowsePage(ctx, cacheKey); err == nil && ok {
|
||||
s.log.Debug("warmBrowseCache: page 1 already cached, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
targetURL := fmt.Sprintf("%s/genre-all/sort-popular/status-all/all-novel?page=1", novelFireBase)
|
||||
s.log.Info("warmBrowseCache: page 1 not cached, triggering background scrape")
|
||||
s.triggerDirectScrape(cacheKey, targetURL)
|
||||
}
|
||||
|
||||
// downloadAndStoreCover delegates to storage.DownloadAndStoreCover.
|
||||
func (s *Server) downloadAndStoreCover(key, imageURL string) {
|
||||
storage.DownloadAndStoreCover(s.store, s.log, key, imageURL)
|
||||
}
|
||||
|
||||
// parseBrowsePage parses the novelfire HTML and extracts novel listings.
|
||||
// Returns novels and whether a "next page" link was found.
|
||||
func parseBrowsePage(r io.Reader) ([]NovelListing, bool) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var novels []NovelListing
|
||||
hasNext := false
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "li":
|
||||
if hasClass(n, "novel-item") {
|
||||
if novel, ok := parseNovelItem(n); ok {
|
||||
novels = append(novels, novel)
|
||||
}
|
||||
}
|
||||
// pagination li with class "next"
|
||||
if hasClass(n, "next") {
|
||||
hasNext = true
|
||||
}
|
||||
case "a":
|
||||
// Detect "next" pagination link
|
||||
if hasClass(n, "next") || attrVal(n, "rel") == "next" {
|
||||
hasNext = true
|
||||
}
|
||||
// Also check aria-label="Next"
|
||||
if attrVal(n, "aria-label") == "Next" {
|
||||
hasNext = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return novels, hasNext
|
||||
}
|
||||
|
||||
// parseNovelItem extracts a NovelListing from a <li class="novel-item"> node.
|
||||
func parseNovelItem(li *html.Node) (NovelListing, bool) {
|
||||
var novel NovelListing
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "a":
|
||||
href := attrVal(n, "href")
|
||||
if strings.HasPrefix(href, "/book/") {
|
||||
slug := strings.TrimPrefix(href, "/book/")
|
||||
slug = strings.TrimSuffix(slug, "/")
|
||||
if novel.Slug == "" {
|
||||
novel.Slug = slug
|
||||
novel.URL = novelFireBase + href
|
||||
}
|
||||
}
|
||||
case "img":
|
||||
// lazy-loaded covers use data-src
|
||||
src := attrVal(n, "data-src")
|
||||
if src == "" {
|
||||
src = attrVal(n, "src")
|
||||
}
|
||||
if src != "" && novel.Cover == "" {
|
||||
if !strings.HasPrefix(src, "http") {
|
||||
src = novelFireBase + src
|
||||
}
|
||||
novel.Cover = src
|
||||
}
|
||||
case "h4":
|
||||
if hasClass(n, "novel-title") && novel.Title == "" {
|
||||
novel.Title = strings.TrimSpace(textContent(n))
|
||||
}
|
||||
case "span":
|
||||
cls := attrVal(n, "class")
|
||||
if strings.Contains(cls, "_bl") && novel.Rank == "" {
|
||||
novel.Rank = strings.TrimSpace(textContent(n))
|
||||
}
|
||||
if strings.Contains(cls, "_br") && novel.Rating == "" {
|
||||
novel.Rating = strings.TrimSpace(textContent(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(li)
|
||||
|
||||
// Extract chapter count from the novel stats text (contains "N Chapters")
|
||||
novel.Chapters = extractChapters(li)
|
||||
|
||||
if novel.Slug == "" || novel.Title == "" {
|
||||
return novel, false
|
||||
}
|
||||
return novel, true
|
||||
}
|
||||
|
||||
// extractChapters finds the chapter count text within a novel-item node.
|
||||
func extractChapters(n *html.Node) string {
|
||||
var result string
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node.Type == html.ElementNode {
|
||||
cls := attrVal(node, "class")
|
||||
if strings.Contains(cls, "novel-stats") || strings.Contains(cls, "chapter") {
|
||||
txt := strings.TrimSpace(textContent(node))
|
||||
if strings.Contains(txt, "Chapter") || strings.Contains(txt, "chapter") {
|
||||
// Extract just the numeric part if possible
|
||||
result = txt
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return result
|
||||
}
|
||||
|
||||
// hasClass reports whether an HTML node has the given CSS class.
|
||||
func hasClass(n *html.Node, cls string) bool {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "class" {
|
||||
for _, c := range strings.Fields(a.Val) {
|
||||
if c == cls {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attrVal returns the value of an attribute on an HTML node, or "".
|
||||
// Delegates to htmlutil.AttrVal.
|
||||
func attrVal(n *html.Node, key string) string { return htmlutil.AttrVal(n, key) }
|
||||
|
||||
// textContent returns the concatenated text content of a node and its descendants.
|
||||
// Delegates to htmlutil.TextContent.
|
||||
func textContent(n *html.Node) string { return htmlutil.TextContent(n) }
|
||||
|
||||
// ─── Search API ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleSearch handles GET /api/search.
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// q — search query string (required, min 2 chars)
|
||||
// source — "local" | "remote" | "all" (default: "all")
|
||||
//
|
||||
// When source includes "local", it searches books already in the local store
|
||||
// by title substring match. When source includes "remote", it fetches the
|
||||
// novelfire.net search page and parses results. Results from both sources
|
||||
// are merged with local results first (de-duplicated by slug).
|
||||
//
|
||||
// Returns JSON: {"results": [...NovelListing], "local_count": N, "remote_count": N}
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if len([]rune(q)) < 2 {
|
||||
http.Error(w, `{"error":"query must be at least 2 characters"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "all"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var localResults []NovelListing
|
||||
var remoteResults []NovelListing
|
||||
|
||||
// ── Local search (PocketBase books) ──────────────────────────────────
|
||||
if source == "local" || source == "all" {
|
||||
books, err := s.store.ListBooks(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("search: ListBooks failed", "err", err)
|
||||
} else {
|
||||
qLower := strings.ToLower(q)
|
||||
for _, b := range books {
|
||||
if strings.Contains(strings.ToLower(b.Title), qLower) ||
|
||||
strings.Contains(strings.ToLower(b.Author), qLower) {
|
||||
listing := NovelListing{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Cover: b.Cover,
|
||||
URL: b.SourceURL,
|
||||
}
|
||||
localResults = append(localResults, listing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remote search (novelfire.net /search?keyword=...) ─────────────────
|
||||
if source == "remote" || source == "all" {
|
||||
searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
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")
|
||||
if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
parsed, _ := parseBrowsePage(resp.Body)
|
||||
remoteResults = parsed
|
||||
} else {
|
||||
s.log.Warn("search: remote returned non-200", "status", resp.StatusCode, "url", searchURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merge: de-duplicate remote results already in local ───────────────
|
||||
localSlugs := make(map[string]bool, len(localResults))
|
||||
for _, item := range localResults {
|
||||
localSlugs[item.Slug] = true
|
||||
}
|
||||
|
||||
combined := make([]NovelListing, 0, len(localResults)+len(remoteResults))
|
||||
combined = append(combined, localResults...)
|
||||
for _, item := range remoteResults {
|
||||
if !localSlugs[item.Slug] {
|
||||
combined = append(combined, item)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"results": combined,
|
||||
"local_count": len(localResults),
|
||||
"remote_count": len(remoteResults),
|
||||
})
|
||||
}
|
||||
168
scraper/internal/server/handlers_preview.go
Normal file
168
scraper/internal/server/handlers_preview.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package server
|
||||
|
||||
// handlers_preview.go — on-demand preview endpoints for books not yet in PocketBase.
|
||||
//
|
||||
// These endpoints allow the UI to display a book's metadata and chapter list
|
||||
// (scraped live from novelfire.net) without requiring a full scrape to have
|
||||
// been run first. They are read-only: nothing is persisted to PocketBase or
|
||||
// MinIO.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/book-preview/{slug} — scrape book metadata + chapter list live
|
||||
// GET /api/chapter-text-preview/{slug}/{n} — scrape a single chapter text live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
// BookPreviewResponse is the JSON response for /api/book-preview/{slug}.
|
||||
type BookPreviewResponse struct {
|
||||
InLib bool `json:"in_lib"`
|
||||
Meta scraper.BookMeta `json:"meta"`
|
||||
Chapters []scraper.ChapterRef `json:"chapters"`
|
||||
}
|
||||
|
||||
// handleBookPreview handles GET /api/book-preview/{slug}.
|
||||
//
|
||||
// It scrapes book metadata and the full chapter list live from novelfire.net.
|
||||
// It also checks whether the book exists in the local store (PocketBase) and
|
||||
// sets the InLib flag accordingly. Nothing is written to any store.
|
||||
//
|
||||
// Query param: source_url (optional) — if provided, uses that URL instead of
|
||||
// constructing one from the slug.
|
||||
func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the book URL: prefer explicit source_url query param.
|
||||
bookURL := r.URL.Query().Get("source_url")
|
||||
if bookURL == "" {
|
||||
bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Check whether the book is already in the local library.
|
||||
_, inLib, err := s.store.ReadMetadata(ctx, slug)
|
||||
if err != nil {
|
||||
// Non-fatal: we can still serve the preview.
|
||||
s.log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err)
|
||||
inLib = false
|
||||
}
|
||||
|
||||
// Scrape live metadata.
|
||||
meta, err := s.novel.ScrapeMetadata(ctx, bookURL)
|
||||
if err != nil {
|
||||
s.log.Error("book-preview: ScrapeMetadata failed", "slug", slug, "url", bookURL, "err", err)
|
||||
http.Error(w, fmt.Sprintf(`{"error":"metadata scrape failed: %s"}`, err.Error()), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Scrape live chapter list.
|
||||
chapters, err := s.novel.ScrapeChapterList(ctx, bookURL)
|
||||
if err != nil {
|
||||
s.log.Error("book-preview: ScrapeChapterList failed", "slug", slug, "url", bookURL, "err", err)
|
||||
// Return partial response with metadata only — chapters are non-critical.
|
||||
chapters = []scraper.ChapterRef{}
|
||||
}
|
||||
|
||||
// If the book was not already in the library, persist the metadata and
|
||||
// chapter list skeleton to PocketBase now so that subsequent visits load
|
||||
// from the local store rather than scraping live again. Chapter text is
|
||||
// NOT fetched here — that still requires an explicit scrape job.
|
||||
if !inLib {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if werr := s.store.WriteMetadata(bgCtx, meta); werr != nil {
|
||||
s.log.Warn("book-preview: WriteMetadata failed (non-fatal)", "slug", slug, "err", werr)
|
||||
}
|
||||
if len(chapters) > 0 {
|
||||
if werr := s.store.WriteChapterRefs(bgCtx, slug, chapters); werr != nil {
|
||||
s.log.Warn("book-preview: WriteChapterRefs failed (non-fatal)", "slug", slug, "err", werr)
|
||||
}
|
||||
}
|
||||
s.log.Info("book-preview: metadata+chapter list persisted", "slug", slug, "chapters", len(chapters))
|
||||
}()
|
||||
inLib = true // will be true by the time the client navigates back
|
||||
}
|
||||
|
||||
resp := BookPreviewResponse{
|
||||
InLib: inLib,
|
||||
Meta: meta,
|
||||
Chapters: chapters,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ChapterPreviewResponse is the JSON response for /api/chapter-text-preview/{slug}/{n}.
|
||||
type ChapterPreviewResponse struct {
|
||||
Slug string `json:"slug"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"` // plain text (markdown stripped)
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}.
|
||||
//
|
||||
// It scrapes a single chapter from novelfire.net live without storing anything.
|
||||
// The chapter URL is determined from either:
|
||||
// - the "chapter_url" query param (preferred — used when the UI knows it from
|
||||
// a prior book-preview call), or
|
||||
// - a best-effort construction: {novelFireBase}/book/{slug}/chapter-{n}
|
||||
//
|
||||
// Returns plain text (markdown stripped) suitable for TTS or display.
|
||||
func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
nStr := r.PathValue("n")
|
||||
n, err := strconv.Atoi(nStr)
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Chapter URL: prefer explicit query param.
|
||||
chapterURL := r.URL.Query().Get("chapter_url")
|
||||
if chapterURL == "" {
|
||||
chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n)
|
||||
}
|
||||
|
||||
title := r.URL.Query().Get("title")
|
||||
|
||||
ref := scraper.ChapterRef{
|
||||
Number: n,
|
||||
Title: title,
|
||||
URL: chapterURL,
|
||||
}
|
||||
|
||||
chapter, err := s.novel.ScrapeChapterText(r.Context(), ref)
|
||||
if err != nil {
|
||||
s.log.Error("chapter-text-preview: ScrapeChapterText failed",
|
||||
"slug", slug, "n", n, "url", chapterURL, "err", err)
|
||||
http.Error(w, fmt.Sprintf(`{"error":"chapter scrape failed: %s"}`, err.Error()), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
resp := ChapterPreviewResponse{
|
||||
Slug: slug,
|
||||
Number: n,
|
||||
Title: chapter.Ref.Title,
|
||||
Text: stripMarkdown(chapter.Text),
|
||||
URL: chapterURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
103
scraper/internal/server/handlers_progress.go
Normal file
103
scraper/internal/server/handlers_progress.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// ─── Reading progress API ─────────────────────────────────────────────────────
|
||||
|
||||
// handleGetProgress handles GET /api/progress.
|
||||
// Returns JSON: {"slug": chapterNum, ...} merged with {"slug_ts": timestampMs, ...}
|
||||
func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ensureSession(w, r)
|
||||
entries, err := s.store.AllProgress(r.Context(), sid)
|
||||
if err != nil {
|
||||
s.log.Error("AllProgress failed", "err", err)
|
||||
entries = nil
|
||||
}
|
||||
|
||||
progress := make(map[string]interface{}, len(entries)*2)
|
||||
for _, p := range entries {
|
||||
progress[p.Slug] = p.Chapter
|
||||
progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(progress)
|
||||
}
|
||||
|
||||
// handleSetProgress handles POST /api/progress/{slug}.
|
||||
// Body: {"chapter": N}
|
||||
func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ensureSession(w, r)
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Chapter int `json:"chapter"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 {
|
||||
http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p := storage.ReadingProgress{
|
||||
Slug: slug,
|
||||
Chapter: body.Chapter,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.store.SetProgress(r.Context(), sid, p); err != nil {
|
||||
s.log.Error("SetProgress failed", "slug", slug, "err", err)
|
||||
http.Error(w, `{"error":"store error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{})
|
||||
}
|
||||
|
||||
// handleDeleteProgress handles DELETE /api/progress/{slug}.
|
||||
func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ensureSession(w, r)
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteProgress(r.Context(), sid, slug); err != nil {
|
||||
s.log.Error("DeleteProgress failed", "slug", slug, "err", err)
|
||||
// Non-fatal — treat as success.
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{})
|
||||
}
|
||||
|
||||
// handleChapterText returns the plain text of a chapter (markdown stripped)
|
||||
// for server-side audio generation. Called by handleAudioGenerate internally.
|
||||
func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
raw, err := s.store.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, stripMarkdown(raw))
|
||||
}
|
||||
86
scraper/internal/server/handlers_ranking.go
Normal file
86
scraper/internal/server/handlers_ranking.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// handleGetRanking returns all ranking items sorted by rank ascending.
|
||||
// Cover fields that hold a MinIO object key (e.g. "novelfire.net/assets/book-covers/slug.jpg")
|
||||
// are rewritten to a /api/cover/{key} proxy URL so the UI can fetch them
|
||||
// without knowing about the internal MinIO topology.
|
||||
func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ReadRankingItems(r.Context())
|
||||
if err != nil {
|
||||
s.log.Error("ranking read failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to read ranking"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []storage.RankingItem{}
|
||||
}
|
||||
// Rewrite cover keys to proxy URLs.
|
||||
// Keys stored by triggerDirectScrape look like:
|
||||
// "novelfire.net/assets/book-covers/shadow-slave.jpg"
|
||||
// We expose them as:
|
||||
// "/api/cover/novelfire.net/shadow-slave"
|
||||
// (the handler strips the domain and slug from the path, reconstructs the key)
|
||||
for i := range items {
|
||||
cover := items[i].Cover
|
||||
if cover != "" && !strings.HasPrefix(cover, "http") {
|
||||
// cover is a MinIO key; extract domain + slug for the proxy path.
|
||||
// Key format: {domain}/assets/book-covers/{slug}.jpg
|
||||
parts := strings.SplitN(cover, "/assets/book-covers/", 2)
|
||||
if len(parts) == 2 {
|
||||
domain := parts[0]
|
||||
slug := strings.TrimSuffix(parts[1], ".jpg")
|
||||
items[i].Cover = "/api/cover/" + domain + "/" + slug
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(items)
|
||||
}
|
||||
|
||||
// handleGetCover proxies a cover image stored in the MinIO browse bucket.
|
||||
// Route: GET /api/cover/{domain}/{slug}
|
||||
// It reconstructs the MinIO key as {domain}/assets/book-covers/{slug}.jpg,
|
||||
// fetches the object, and streams it to the client.
|
||||
// Returns 404 if not yet downloaded, allowing the UI to fall back to the
|
||||
// original source URL.
|
||||
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
domain := r.PathValue("domain")
|
||||
slug := r.PathValue("slug")
|
||||
if domain == "" || slug == "" {
|
||||
http.Error(w, "missing domain or slug", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key := s.store.BrowseCoverKey(domain, slug)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, contentType, ok, err := s.store.GetBrowseAsset(ctx, key)
|
||||
if err != nil {
|
||||
s.log.Warn("handleGetCover: GetBrowseAsset error", "key", key, "err", err)
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
270
scraper/internal/server/handlers_scrape.go
Normal file
270
scraper/internal/server/handlers_scrape.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/orchestrator"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.oCfg
|
||||
cfg.SingleBookURL = "" // full catalogue
|
||||
|
||||
s.runAsync(w, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
|
||||
http.Error(w, `{"error":"request body must be JSON with \"url\" field"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := s.oCfg
|
||||
cfg.SingleBookURL = body.URL
|
||||
|
||||
s.runAsync(w, cfg)
|
||||
}
|
||||
|
||||
// handleScrapeBookRange handles POST /api/scrape/book/range.
|
||||
// Body: {"url": "...", "from": N, "to": M}
|
||||
// Scrapes only chapters in the range [from, to] (inclusive).
|
||||
// from=0 means "start from chapter 1"; to=0 means "no upper limit".
|
||||
func (s *Server) handleScrapeBookRange(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
|
||||
http.Error(w, `{"error":"request body must be JSON with \"url\" field"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := s.oCfg
|
||||
cfg.SingleBookURL = body.URL
|
||||
cfg.FromChapter = body.From
|
||||
cfg.ToChapter = body.To
|
||||
|
||||
s.runAsync(w, cfg)
|
||||
}
|
||||
|
||||
// runAsync launches an orchestrator in the background and returns 202 Accepted.
|
||||
// Only one scrape job runs at a time; concurrent requests receive 409 Conflict.
|
||||
func (s *Server) runAsync(w http.ResponseWriter, cfg orchestrator.Config) {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, `{"error":"a scrape job is already running"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
|
||||
defer cancel()
|
||||
|
||||
// Determine task kind and target.
|
||||
kind := "catalogue"
|
||||
targetURL := ""
|
||||
if cfg.SingleBookURL != "" {
|
||||
kind = "book"
|
||||
targetURL = cfg.SingleBookURL
|
||||
}
|
||||
|
||||
// Create the task record in PocketBase.
|
||||
taskID, err := s.store.CreateScrapeTask(ctx, kind, targetURL)
|
||||
if err != nil {
|
||||
s.log.Warn("could not create scraping_tasks record", "err", err)
|
||||
// Non-fatal: continue without task tracking.
|
||||
}
|
||||
|
||||
// flush pushes the latest counters to PocketBase (best-effort).
|
||||
flush := func(p orchestrator.Progress, status, errMsg string, finished bool) {
|
||||
if taskID == "" {
|
||||
return
|
||||
}
|
||||
u := storage.ScrapeTaskUpdate{
|
||||
Status: status,
|
||||
BooksFound: p.BooksFound,
|
||||
ChaptersScraped: p.ChaptersScraped,
|
||||
ChaptersSkipped: p.ChaptersSkipped,
|
||||
Errors: p.Errors,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
if finished {
|
||||
u.Finished = time.Now().UTC()
|
||||
}
|
||||
if updateErr := s.store.UpdateScrapeTask(ctx, taskID, u); updateErr != nil {
|
||||
s.log.Warn("could not update scraping_tasks record", "task_id", taskID, "err", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.OnProgress = func(p orchestrator.Progress) {
|
||||
flush(p, "running", "", false)
|
||||
}
|
||||
|
||||
o := orchestrator.New(cfg, s.novel, s.log, s.store)
|
||||
runErr := o.Run(ctx)
|
||||
|
||||
// After a successful full-catalogue run, refresh the ranking list.
|
||||
if runErr == nil && cfg.SingleBookURL == "" {
|
||||
s.log.Info("runAsync: starting ScrapeRanking after catalogue run")
|
||||
rankCtx, rankCancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer rankCancel()
|
||||
rankEntries, rankErrs := s.novel.ScrapeRanking(rankCtx, 0)
|
||||
rank := 1
|
||||
for meta := range rankEntries {
|
||||
item := storage.RankingItem{
|
||||
Rank: rank,
|
||||
Slug: meta.Slug,
|
||||
Title: meta.Title,
|
||||
Author: meta.Author,
|
||||
Cover: meta.Cover,
|
||||
Status: meta.Status,
|
||||
Genres: meta.Genres,
|
||||
SourceURL: meta.SourceURL,
|
||||
}
|
||||
if werr := s.store.WriteRankingItem(rankCtx, item); werr != nil {
|
||||
s.log.Warn("runAsync: WriteRankingItem failed", "slug", meta.Slug, "err", werr)
|
||||
}
|
||||
rank++
|
||||
}
|
||||
if rerr := <-rankErrs; rerr != nil {
|
||||
s.log.Warn("runAsync: ScrapeRanking finished with error", "err", rerr)
|
||||
} else {
|
||||
s.log.Info("runAsync: ScrapeRanking complete", "count", rank-1)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final status.
|
||||
finalStatus := "done"
|
||||
errMsg := ""
|
||||
if runErr != nil {
|
||||
s.log.Error("scrape job failed", "err", fmt.Sprintf("%v", runErr))
|
||||
if ctx.Err() != nil {
|
||||
finalStatus = "cancelled"
|
||||
} else {
|
||||
finalStatus = "failed"
|
||||
}
|
||||
errMsg = runErr.Error()
|
||||
}
|
||||
|
||||
// Best-effort: read last known progress counters via a zero-value
|
||||
// OnProgress — we don't have a snapshot here, so re-use whatever the
|
||||
// last OnProgress call delivered (the orchestrator calls notify() at
|
||||
// the very end, so this is always accurate after Run returns).
|
||||
// We issue one final flush with the terminal status and finished time.
|
||||
if taskID != "" {
|
||||
// Re-fetch current counters by listing the task (cheapest path).
|
||||
tasks, listErr := s.store.ListScrapeTasks(ctx)
|
||||
var last storage.ScrapeTaskUpdate
|
||||
if listErr == nil {
|
||||
for _, t := range tasks {
|
||||
if t.ID == taskID {
|
||||
last = storage.ScrapeTaskUpdate{
|
||||
BooksFound: t.BooksFound,
|
||||
ChaptersScraped: t.ChaptersScraped,
|
||||
ChaptersSkipped: t.ChaptersSkipped,
|
||||
Errors: t.Errors,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
last.Status = finalStatus
|
||||
last.ErrorMessage = errMsg
|
||||
last.Finished = time.Now().UTC()
|
||||
if updateErr := s.store.UpdateScrapeTask(ctx, taskID, last); updateErr != nil {
|
||||
s.log.Warn("could not finalize scraping_tasks record", "task_id", taskID, "err", updateErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ─── Scrape status API ────────────────────────────────────────────────────────
|
||||
|
||||
// handleScrapeStatus handles GET /api/scrape/status.
|
||||
// Returns JSON: {"running": bool}
|
||||
func (s *Server) handleScrapeStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
running := s.running
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"running": running})
|
||||
}
|
||||
|
||||
// handleScrapeTasks handles GET /api/scrape/tasks.
|
||||
// Returns JSON array of all scraping_tasks records, newest first.
|
||||
func (s *Server) handleScrapeTasks(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.store.ListScrapeTasks(r.Context())
|
||||
if err != nil {
|
||||
s.log.Error("handleScrapeTasks: list failed", "err", err)
|
||||
http.Error(w, `{"error":"failed to list tasks"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []storage.ScrapeTask{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tasks)
|
||||
}
|
||||
|
||||
// handleReindex handles POST /api/reindex/{slug}.
|
||||
// It rebuilds the chapters_idx PocketBase collection for the given book by
|
||||
// walking its MinIO objects. Use this when chapters were scraped but the index
|
||||
// is out of sync (e.g. after a failed UpsertChapterIdx during scraping).
|
||||
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type reindexer interface {
|
||||
ReindexChapters(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
ri, ok := s.store.(reindexer)
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"store does not support reindex"}`, http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := ri.ReindexChapters(r.Context(), slug)
|
||||
if err != nil {
|
||||
s.log.Error("reindex failed", "slug", slug, "indexed", count, "err", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"indexed": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Info("reindex complete", "slug", slug, "indexed", count)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"slug": slug,
|
||||
"indexed": count,
|
||||
})
|
||||
}
|
||||
62
scraper/internal/server/helpers.go
Normal file
62
scraper/internal/server/helpers.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// kokoroVoices is the built-in fallback list of voices shipped with Kokoro-FastAPI.
|
||||
// Used when the live GET /v1/audio/voices request to Kokoro fails.
|
||||
// Grouped by language prefix:
|
||||
//
|
||||
// af_ / am_ American English female / male
|
||||
// bf_ / bm_ British English female / male
|
||||
// ef_ / em_ Spanish female / male
|
||||
// ff_ French female
|
||||
// hf_ / hm_ Hindi female / male
|
||||
// if_ / im_ Italian female / male
|
||||
// jf_ / jm_ Japanese female / male
|
||||
// pf_ / pm_ Portuguese female / male
|
||||
// zf_ / zm_ Chinese female / male
|
||||
var kokoroVoices = []string{
|
||||
// American English
|
||||
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
|
||||
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
|
||||
"af_sarah", "af_sky",
|
||||
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
|
||||
"am_michael", "am_onyx", "am_puck",
|
||||
// British English
|
||||
"bf_alice", "bf_emma", "bf_lily",
|
||||
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
|
||||
// Spanish
|
||||
"ef_dora", "em_alex",
|
||||
// French
|
||||
"ff_siwis",
|
||||
// Hindi
|
||||
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
|
||||
// Italian
|
||||
"if_sara", "im_nicola",
|
||||
// Japanese
|
||||
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
|
||||
// Portuguese
|
||||
"pf_dora", "pm_alex",
|
||||
// Chinese
|
||||
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
|
||||
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
|
||||
}
|
||||
|
||||
// stripMarkdown removes common markdown syntax from src, returning plain text
|
||||
// suitable for TTS or display. Not a full markdown parser — handles the most
|
||||
// common constructs (headings, bold/italic, code blocks, links, blockquotes).
|
||||
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)
|
||||
}
|
||||
412
scraper/internal/server/integration_test.go
Normal file
412
scraper/internal/server/integration_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests for the HTTP server against live MinIO + PocketBase backends.
|
||||
//
|
||||
// The server is started on a random port for each test; real HybridStore
|
||||
// backends are used. Browserless-dependent tests are skipped unless
|
||||
// BROWSERLESS_URL is set.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// MINIO_ENDPOINT=localhost:9000 \
|
||||
// POCKETBASE_URL=http://localhost:8090 \
|
||||
// go test -v -tags integration -timeout 120s \
|
||||
// github.com/libnovel/scraper/internal/server
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/orchestrator"
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// ─── fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// newTestStore creates a HybridStore from env vars, skipping if not configured.
|
||||
func newTestStore(t *testing.T) *storage.HybridStore {
|
||||
t.Helper()
|
||||
if os.Getenv("MINIO_ENDPOINT") == "" {
|
||||
t.Skip("MINIO_ENDPOINT not set — skipping server integration test")
|
||||
}
|
||||
if os.Getenv("POCKETBASE_URL") == "" {
|
||||
t.Skip("POCKETBASE_URL not set — skipping server integration test")
|
||||
}
|
||||
|
||||
pbCfg := storage.PocketBaseConfig{
|
||||
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
||||
AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"),
|
||||
AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"),
|
||||
}
|
||||
minioCfg := storage.MinioConfig{
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: envOr("MINIO_USE_SSL", "false") == "true",
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := storage.NewHybridStore(ctx, pbCfg, minioCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
// startTestServer starts a real Server on a random free port and returns the
|
||||
// base URL. The server is shut down when the test finishes.
|
||||
func startTestServer(t *testing.T, store storage.Store) string {
|
||||
t.Helper()
|
||||
|
||||
// Find a free port.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
|
||||
// nopScraper satisfies scraper.NovelScraper without hitting the network.
|
||||
srv := New(addr, orchestrator.Config{}, nopScraper{}, log, store, "", "af_bella")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
// Signal readiness after a short delay to let the listener bind.
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(ready)
|
||||
}()
|
||||
_ = srv.ListenAndServe(ctx)
|
||||
}()
|
||||
|
||||
<-ready
|
||||
|
||||
// Wait until the server actually accepts connections.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
return "http://" + addr
|
||||
}
|
||||
|
||||
// nopScraper is a no-op NovelScraper implementation for tests that don't
|
||||
// exercise scraping functionality.
|
||||
type nopScraper struct{}
|
||||
|
||||
func (nopScraper) SourceName() string { return "nop" }
|
||||
func (nopScraper) ScrapeCatalogue(_ context.Context) (<-chan scraper.CatalogueEntry, <-chan error) {
|
||||
ch := make(chan scraper.CatalogueEntry)
|
||||
errs := make(chan error)
|
||||
close(ch)
|
||||
close(errs)
|
||||
return ch, errs
|
||||
}
|
||||
func (nopScraper) ScrapeMetadata(_ context.Context, _ string) (scraper.BookMeta, error) {
|
||||
return scraper.BookMeta{}, nil
|
||||
}
|
||||
func (nopScraper) ScrapeChapterList(_ context.Context, _ string) ([]scraper.ChapterRef, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (nopScraper) ScrapeChapterText(_ context.Context, ref scraper.ChapterRef) (scraper.Chapter, error) {
|
||||
return scraper.Chapter{Ref: ref}, nil
|
||||
}
|
||||
func (nopScraper) ScrapeRanking(_ context.Context, _ int) (<-chan scraper.BookMeta, <-chan error) {
|
||||
ch := make(chan scraper.BookMeta)
|
||||
errs := make(chan error)
|
||||
close(ch)
|
||||
close(errs)
|
||||
return ch, errs
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestServer_Health verifies GET /health returns 200 with status:ok.
|
||||
func TestServer_Health(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := startTestServer(t, store)
|
||||
|
||||
resp, err := http.Get(base + "/health")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /health: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode health body: %v", err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("status field = %q, want %q", body["status"], "ok")
|
||||
}
|
||||
t.Logf("health response: %v", body)
|
||||
}
|
||||
|
||||
// TestServer_ScrapeStatus verifies GET /api/scrape/status returns running:false
|
||||
// when no scrape is running.
|
||||
func TestServer_ScrapeStatus(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := startTestServer(t, store)
|
||||
|
||||
resp, err := http.Get(base + "/api/scrape/status")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/scrape/status: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]bool
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["running"] {
|
||||
t.Error("scrape/status.running = true, want false")
|
||||
}
|
||||
t.Logf("scrape status: %v", body)
|
||||
}
|
||||
|
||||
// TestServer_PresignChapter writes a chapter to MinIO, then calls
|
||||
// GET /api/presign/chapter/{slug}/{n} and verifies a URL is returned.
|
||||
func TestServer_PresignChapter(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := startTestServer(t, store)
|
||||
|
||||
// Write a chapter directly via the store so we have something to presign.
|
||||
slug := fmt.Sprintf("server-presign-test-%d", time.Now().UnixMilli()%100000)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := scraper.Chapter{
|
||||
Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Server Presign Test", Volume: 0},
|
||||
Text: "Content for the server presign integration test.",
|
||||
}
|
||||
if err := store.WriteChapter(ctx, slug, ch); err != nil {
|
||||
t.Fatalf("WriteChapter: %v", err)
|
||||
}
|
||||
t.Logf("stored chapter for slug=%q", slug)
|
||||
|
||||
// Call the presign endpoint.
|
||||
url := fmt.Sprintf("%s/api/presign/chapter/%s/1", base, slug)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode presign response: %v", err)
|
||||
}
|
||||
presignedURL := body["url"]
|
||||
if presignedURL == "" {
|
||||
t.Fatal("presign response has empty url field")
|
||||
}
|
||||
if !strings.HasPrefix(presignedURL, "http") {
|
||||
t.Errorf("presigned URL does not start with http: %q", presignedURL)
|
||||
}
|
||||
t.Logf("presigned URL: %s", presignedURL)
|
||||
}
|
||||
|
||||
// TestServer_Progress exercises POST /api/progress/{slug} and GET /api/progress.
|
||||
func TestServer_Progress(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := startTestServer(t, store)
|
||||
|
||||
slug := fmt.Sprintf("server-progress-test-%d", time.Now().UnixMilli()%100000)
|
||||
|
||||
// Use a persistent http.Client to carry the session cookie.
|
||||
jar := &cookieJar{cookies: make(map[string][]*http.Cookie)}
|
||||
client := &http.Client{Jar: jar}
|
||||
|
||||
// POST /api/progress/{slug}
|
||||
setURL := fmt.Sprintf("%s/api/progress/%s", base, slug)
|
||||
body, _ := json.Marshal(map[string]int{"chapter": 5})
|
||||
resp, err := client.Post(setURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", setURL, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST progress status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
t.Logf("POST /api/progress/%s → %d", slug, resp.StatusCode)
|
||||
|
||||
// GET /api/progress
|
||||
getURL := fmt.Sprintf("%s/api/progress", base)
|
||||
resp2, err := client.Get(getURL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", getURL, err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET progress status = %d, want 200", resp2.StatusCode)
|
||||
}
|
||||
|
||||
var progress map[string]interface{}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&progress); err != nil {
|
||||
t.Fatalf("decode progress response: %v", err)
|
||||
}
|
||||
t.Logf("progress: %v", progress)
|
||||
|
||||
// The slug should appear with chapter value 5.
|
||||
if ch, ok := progress[slug]; !ok {
|
||||
t.Errorf("slug %q not found in progress map; keys: %v", slug, mapKeys(progress))
|
||||
} else {
|
||||
// JSON numbers decode as float64.
|
||||
chNum, _ := ch.(float64)
|
||||
if int(chNum) != 5 {
|
||||
t.Errorf("progress[%q] = %v, want 5", slug, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/progress/{slug}
|
||||
delURL := fmt.Sprintf("%s/api/progress/%s", base, slug)
|
||||
delReq, _ := http.NewRequest(http.MethodDelete, delURL, nil)
|
||||
delResp, err := client.Do(delReq)
|
||||
if err != nil {
|
||||
t.Fatalf("DELETE %s: %v", delURL, err)
|
||||
}
|
||||
delResp.Body.Close()
|
||||
if delResp.StatusCode != http.StatusOK {
|
||||
t.Errorf("DELETE progress status = %d, want 200", delResp.StatusCode)
|
||||
}
|
||||
t.Logf("DELETE /api/progress/%s → %d", slug, delResp.StatusCode)
|
||||
}
|
||||
|
||||
// TestServer_PresignChapter_NotFound verifies that presigning a non-existent
|
||||
// chapter returns 500 (presign fails on missing object).
|
||||
func TestServer_PresignChapter_NotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := startTestServer(t, store)
|
||||
|
||||
url := fmt.Sprintf("%s/api/presign/chapter/does-not-exist-slug/999", base)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// MinIO presign on a non-existent key returns an error; server returns 500.
|
||||
// (Some MinIO versions return a valid presigned URL anyway, which is also acceptable.)
|
||||
t.Logf("presign non-existent chapter status: %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusInternalServerError && resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 500 or 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_ChapterText writes a chapter and verifies
|
||||
// GET /api/chapter-text/{slug}/{n} returns the stripped plain text.
|
||||
func TestServer_ChapterText(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
base := startTestServer(t, store)
|
||||
|
||||
slug := fmt.Sprintf("server-chtext-test-%d", time.Now().UnixMilli()%100000)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const chapterText = "The quick brown fox jumps over the lazy dog near the river."
|
||||
ch := scraper.Chapter{
|
||||
Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Text Test", Volume: 0},
|
||||
Text: chapterText,
|
||||
}
|
||||
if err := store.WriteChapter(ctx, slug, ch); err != nil {
|
||||
t.Fatalf("WriteChapter: %v", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/chapter-text/%s/1", base, slug)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
rawBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
buf.Write(rawBytes)
|
||||
text := buf.String()
|
||||
t.Logf("chapter text (%d bytes): %q", len(text), text[:min(len(text), 120)])
|
||||
|
||||
if text == "" {
|
||||
t.Error("chapter-text returned empty body")
|
||||
}
|
||||
// The stripped text should contain our chapter text (markdown heading stripped).
|
||||
if !strings.Contains(text, chapterText) {
|
||||
t.Errorf("chapter text does not contain expected content %q", chapterText)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func mapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// cookieJar is a minimal http.CookieJar that stores cookies by host.
|
||||
type cookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
}
|
||||
|
||||
func (j *cookieJar) SetCookies(u *neturl.URL, cookies []*http.Cookie) {
|
||||
j.cookies[u.Host] = append(j.cookies[u.Host], cookies...)
|
||||
}
|
||||
|
||||
func (j *cookieJar) Cookies(u *neturl.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
@@ -1,68 +1,90 @@
|
||||
// Package server exposes the scraper as an HTTP service.
|
||||
// Package server exposes the scraper as an HTTP API service.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// POST /scrape — enqueue a full catalogue scrape
|
||||
// POST /scrape/book — enqueue a single-book scrape (JSON body: {"url":"..."})
|
||||
// GET /health — liveness probe
|
||||
// POST /scrape — enqueue a full catalogue scrape
|
||||
// POST /scrape/book — enqueue a single-book scrape (JSON body: {"url":"..."})
|
||||
// GET /health — liveness probe
|
||||
// GET /api/progress — get reading progress map (session-scoped)
|
||||
// POST /api/progress/{slug} — set reading progress
|
||||
// DELETE /api/progress/{slug} — delete reading progress
|
||||
// GET /api/presign/chapter/{slug}/{n} — presigned MinIO URL for chapter markdown
|
||||
// GET /api/presign/audio/{slug}/{n} — presigned MinIO URL for chapter audio
|
||||
// GET /api/chapter-text/{slug}/{n} — plain text of chapter (markdown stripped)
|
||||
// POST /api/audio/{slug}/{n} — trigger Kokoro audio generation
|
||||
// GET /api/audio-proxy/{slug}/{n} — proxy generated audio from Kokoro
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/orchestrator"
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/writer"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
)
|
||||
|
||||
// Server wraps an HTTP mux with the scraping endpoints.
|
||||
type Server struct {
|
||||
addr string
|
||||
oCfg orchestrator.Config
|
||||
novel scraper.NovelScraper
|
||||
log *slog.Logger
|
||||
writer *writer.Writer
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
rankingRunning bool
|
||||
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
|
||||
kokoroVoice string // default voice, e.g. af_bella
|
||||
addr string
|
||||
oCfg orchestrator.Config
|
||||
novel scraper.NovelScraper
|
||||
log *slog.Logger
|
||||
store storage.Store
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880
|
||||
kokoroVoice string // default voice, e.g. af_bella
|
||||
|
||||
// voiceMu guards cachedVoices.
|
||||
voiceMu sync.RWMutex
|
||||
cachedVoices []string // populated on first request from Kokoro /v1/audio/voices
|
||||
|
||||
// audioMu guards audioCache and audioInFlight.
|
||||
// audioCache maps a cache key to the Kokoro download filename returned by
|
||||
// POST /v1/audio/speech with return_download_link=true.
|
||||
// audioMu guards audioInFlight only.
|
||||
// Completed audio filenames are persisted to the Store (PocketBase).
|
||||
// audioInFlight deduplicates concurrent generation requests for the same key.
|
||||
audioMu sync.Mutex
|
||||
audioCache map[string]string // cacheKey → kokoro download filename
|
||||
audioInFlight map[string]chan struct{} // cacheKey → closed when done
|
||||
|
||||
// browseMu guards browseInFlight — keys currently being refreshed
|
||||
// in the background.
|
||||
browseMu sync.Mutex
|
||||
browseInFlight map[string]struct{}
|
||||
|
||||
// browseMemCache is a short-lived in-process cache for browse results.
|
||||
// It is populated whenever a live upstream fetch succeeds and used as a
|
||||
// last-resort fallback when both MinIO and the upstream are unavailable.
|
||||
// Key: the MinIO cache key (same as used for BrowseHTMLKey).
|
||||
browseMemCacheMu sync.RWMutex
|
||||
browseMemCache map[string]browseCacheEntry
|
||||
}
|
||||
|
||||
type browseCacheEntry struct {
|
||||
novels []NovelListing
|
||||
hasNext bool
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// New creates a new Server.
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, kokoroURL, kokoroVoice string) *Server {
|
||||
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
|
||||
return &Server{
|
||||
addr: addr,
|
||||
oCfg: oCfg,
|
||||
novel: novel,
|
||||
log: log,
|
||||
writer: writer.New(oCfg.StaticRoot),
|
||||
kokoroURL: kokoroURL,
|
||||
kokoroVoice: kokoroVoice,
|
||||
audioCache: make(map[string]string),
|
||||
audioInFlight: make(map[string]chan struct{}),
|
||||
addr: addr,
|
||||
oCfg: oCfg,
|
||||
novel: novel,
|
||||
log: log,
|
||||
store: store,
|
||||
kokoroURL: kokoroURL,
|
||||
kokoroVoice: kokoroVoice,
|
||||
audioInFlight: make(map[string]chan struct{}),
|
||||
browseInFlight: make(map[string]struct{}),
|
||||
browseMemCache: make(map[string]browseCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,31 +100,36 @@ func (s *Server) voices() []string {
|
||||
return cached
|
||||
}
|
||||
|
||||
if s.kokoroURL != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
var payload struct {
|
||||
Voices []string `json:"voices"`
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK && json.NewDecoder(resp.Body).Decode(&payload) == nil && len(payload.Voices) > 0 {
|
||||
s.voiceMu.Lock()
|
||||
s.cachedVoices = payload.Voices
|
||||
s.voiceMu.Unlock()
|
||||
s.log.Info("fetched kokoro voices", "count", len(payload.Voices))
|
||||
return payload.Voices
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Warn("could not fetch kokoro voices, using built-in list")
|
||||
if s.kokoroURL == "" {
|
||||
return kokoroVoices
|
||||
}
|
||||
|
||||
return kokoroVoices
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil)
|
||||
if err != nil {
|
||||
s.log.Warn("could not fetch kokoro voices, using built-in list", "err", err)
|
||||
return kokoroVoices
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
s.log.Warn("could not fetch kokoro voices, using built-in list", "err", err)
|
||||
return kokoroVoices
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var payload struct {
|
||||
Voices []string `json:"voices"`
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&payload) != nil || len(payload.Voices) == 0 {
|
||||
s.log.Warn("could not fetch kokoro voices, using built-in list")
|
||||
return kokoroVoices
|
||||
}
|
||||
s.voiceMu.Lock()
|
||||
s.cachedVoices = payload.Voices
|
||||
s.voiceMu.Unlock()
|
||||
s.log.Info("fetched kokoro voices", "count", len(payload.Voices))
|
||||
return payload.Voices
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server and blocks until the provided context
|
||||
@@ -112,20 +139,43 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
// UI routes
|
||||
mux.HandleFunc("GET /", s.handleHome)
|
||||
mux.HandleFunc("GET /scrape", s.handleScrape)
|
||||
mux.HandleFunc("GET /ranking", s.handleRanking)
|
||||
mux.HandleFunc("POST /ranking/refresh", s.handleRankingRefresh)
|
||||
mux.HandleFunc("GET /ranking/view", s.handleRankingView)
|
||||
mux.HandleFunc("GET /books/{slug}", s.handleBook)
|
||||
mux.HandleFunc("GET /books/{slug}/chapters/{n}", s.handleChapter)
|
||||
mux.HandleFunc("GET /books/{slug}/chapters-page", s.handleBookChaptersPage)
|
||||
mux.HandleFunc("POST /ui/scrape/book", s.handleUIScrapeBook)
|
||||
mux.HandleFunc("GET /ui/scrape/status", s.handleUIScrapeStatus)
|
||||
mux.HandleFunc("GET /ui/ranking/status", s.handleRankingStatus)
|
||||
// Plain-text chapter content for browser-side TTS
|
||||
mux.HandleFunc("GET /ui/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||
// Browse API — fetches and parses novelfire catalogue page
|
||||
mux.HandleFunc("GET /api/browse", s.handleBrowse)
|
||||
// Ranking API
|
||||
mux.HandleFunc("GET /api/ranking", s.handleGetRanking)
|
||||
// Cover image proxy (serves images stored in browse MinIO bucket)
|
||||
mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover)
|
||||
// Scrape status
|
||||
mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus)
|
||||
mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)
|
||||
// Re-index chapters for a book from MinIO into PocketBase chapters_idx
|
||||
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
||||
// On-demand preview (no store writes) — for books not yet in the library
|
||||
mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview)
|
||||
mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview)
|
||||
// Search: local PocketBase + remote novelfire.net
|
||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||
// Progress API
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
|
||||
// Presigned URL API (for SvelteKit UI)
|
||||
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)
|
||||
// Plain-text chapter content (used server-side for audio generation)
|
||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||
// Voices list (proxied from Kokoro)
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
// Voice sample generation — generates a short audio clip for each voice
|
||||
// and stores it in MinIO for UI preview playback.
|
||||
voiceSampleHandler := http.TimeoutHandler(
|
||||
http.HandlerFunc(s.handleGenerateVoiceSamples),
|
||||
15*time.Minute,
|
||||
`{"error":"voice sample generation timed out"}`,
|
||||
)
|
||||
mux.Handle("POST /api/audio/voice-samples", voiceSampleHandler)
|
||||
// Server-side audio generation via Kokoro /v1/audio/speech.
|
||||
// Generation can take several minutes, so wrap in its own timeout handler.
|
||||
audioGenHandler := http.TimeoutHandler(
|
||||
@@ -133,11 +183,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
10*time.Minute,
|
||||
`{"error":"audio generation timed out"}`,
|
||||
)
|
||||
mux.Handle("POST /ui/audio/{slug}/{n}", audioGenHandler)
|
||||
// Status route: returns the proxy URL if audio was already generated, 404 otherwise.
|
||||
mux.HandleFunc("GET /ui/audio/{slug}/{n}", s.handleAudioStatus)
|
||||
mux.Handle("POST /api/audio/{slug}/{n}", audioGenHandler)
|
||||
// Proxy route: fetches the generated file from Kokoro /v1/download/{filename}.
|
||||
mux.HandleFunc("GET /ui/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.addr,
|
||||
@@ -152,6 +200,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
|
||||
s.log.Info("HTTP server listening", "addr", s.addr)
|
||||
|
||||
// Pre-populate voice samples in the background so the UI voice selector
|
||||
// has playable previews without requiring a manual trigger.
|
||||
go s.warmVoiceSamples(ctx)
|
||||
|
||||
// Warm the browse cache on startup: if page 1 is not cached in MinIO yet,
|
||||
// trigger a background SingleFile snapshot immediately so the first user
|
||||
// request is served from cache rather than hitting novelfire.net live.
|
||||
go s.warmBrowseCache()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -167,339 +224,46 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleChapterText returns the plain text of a chapter (markdown stripped)
|
||||
// for browser-side TTS. The browser POSTs this directly to Kokoro-FastAPI.
|
||||
func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
raw, err := s.writer.ReadChapter(slug, n)
|
||||
// ─── Session cookie helpers ───────────────────────────────────────────────────
|
||||
|
||||
const sessionCookieName = "libnovel_session"
|
||||
|
||||
// sessionID returns the session ID from the request cookie, or "" if absent.
|
||||
func sessionID(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
return ""
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, stripMarkdown(raw))
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
|
||||
//
|
||||
// handleAudioGenerate handles POST /ui/audio/{slug}/{n}.
|
||||
//
|
||||
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
|
||||
// Kokoro generates the audio, saves it to its own temp storage, and returns
|
||||
// the download filename in the X-Download-Path response header.
|
||||
// We cache that filename (in memory, keyed by slug/chapter/voice/speed) and
|
||||
// return a proxy URL that the browser sets as audio.src.
|
||||
//
|
||||
// On a cache hit the proxy URL is returned immediately without re-generating.
|
||||
// Concurrent requests for the same key are deduplicated.
|
||||
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
||||
return
|
||||
// newSessionID generates a random 16-byte hex session ID.
|
||||
func newSessionID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse optional voice/speed from JSON body.
|
||||
voice := s.kokoroVoice
|
||||
speed := 1.0
|
||||
var body struct {
|
||||
Voice string `json:"voice"`
|
||||
Speed float64 `json:"speed"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
if body.Voice != "" {
|
||||
voice = body.Voice
|
||||
}
|
||||
if body.Speed > 0 {
|
||||
speed = body.Speed
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
||||
|
||||
// Fast path: already generated this session.
|
||||
s.audioMu.Lock()
|
||||
if filename, ok := s.audioCache[cacheKey]; ok {
|
||||
s.audioMu.Unlock()
|
||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate concurrent generation for the same key.
|
||||
if ch, ok := s.audioInFlight[cacheKey]; ok {
|
||||
s.audioMu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-r.Context().Done():
|
||||
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
s.audioMu.Lock()
|
||||
filename, ok := s.audioCache[cacheKey]
|
||||
s.audioMu.Unlock()
|
||||
if ok {
|
||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
||||
} else {
|
||||
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
s.audioInFlight[cacheKey] = ch
|
||||
s.audioMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.audioMu.Lock()
|
||||
delete(s.audioInFlight, cacheKey)
|
||||
s.audioMu.Unlock()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// Load and validate chapter text.
|
||||
raw, err := s.writer.ReadChapter(slug, n)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
text := stripMarkdown(raw)
|
||||
if text == "" {
|
||||
http.Error(w, `{"error":"chapter text is empty"}`, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if s.kokoroURL == "" {
|
||||
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Call Kokoro POST /v1/audio/speech with return_download_link=true.
|
||||
// Kokoro saves the generated audio to its own temp storage and returns the
|
||||
// download path in the X-Download-Path response header.
|
||||
filename, err := s.generateSpeech(r.Context(), text, voice, speed)
|
||||
if err != nil {
|
||||
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
|
||||
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
s.audioMu.Lock()
|
||||
s.audioCache[cacheKey] = filename
|
||||
s.audioMu.Unlock()
|
||||
|
||||
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
|
||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
|
||||
// and returns the filename from the X-Download-Path response header.
|
||||
func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "mp3",
|
||||
"speed": speed,
|
||||
"stream": false,
|
||||
"return_download_link": true,
|
||||
// ensureSession issues a new session cookie if the request does not already
|
||||
// carry one, and returns the session ID (either existing or newly issued).
|
||||
func ensureSession(w http.ResponseWriter, r *http.Request) string {
|
||||
if id := sessionID(r); id != "" {
|
||||
return id
|
||||
}
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
// Very unlikely, but fall back to a timestamp-based ID.
|
||||
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, // 1 year
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kokoro request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Drain body so the connection can be reused.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("kokoro status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// X-Download-Path is e.g. "/download/speech_abc123.mp3"
|
||||
dlPath := resp.Header.Get("X-Download-Path")
|
||||
if dlPath == "" {
|
||||
return "", fmt.Errorf("kokoro did not return X-Download-Path header")
|
||||
}
|
||||
|
||||
// Extract just the filename from the path.
|
||||
filename := dlPath
|
||||
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
|
||||
filename = dlPath[idx+1:]
|
||||
}
|
||||
if filename == "" {
|
||||
return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// writeAudioResponse writes the JSON response for a generated audio chapter.
|
||||
// The URL points to our proxy handler which fetches from Kokoro on demand.
|
||||
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, speed float64, filename string) {
|
||||
proxyURL := fmt.Sprintf("/ui/audio-proxy/%s/%d?voice=%s&speed=%.1f", slug, n, voice, speed)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"url": proxyURL,
|
||||
"filename": filename,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAudioStatus handles GET /ui/audio/{slug}/{n}.
|
||||
// Returns the proxy URL if audio was already generated this session, 404 otherwise.
|
||||
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.kokoroVoice
|
||||
}
|
||||
speedStr := r.URL.Query().Get("speed")
|
||||
speed := 1.0
|
||||
if speedStr != "" {
|
||||
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
|
||||
speed = v
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
||||
s.audioMu.Lock()
|
||||
filename, ok := s.audioCache[cacheKey]
|
||||
s.audioMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"not generated"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
||||
}
|
||||
|
||||
// handleAudioProxy handles GET /ui/audio-proxy/{slug}/{n}.
|
||||
// It looks up the Kokoro download filename for this chapter (voice/speed) and
|
||||
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
||||
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.kokoroVoice
|
||||
}
|
||||
speedStr := r.URL.Query().Get("speed")
|
||||
speed := 1.0
|
||||
if speedStr != "" {
|
||||
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
|
||||
speed = v
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
||||
s.audioMu.Lock()
|
||||
filename, ok := s.audioCache[cacheKey]
|
||||
s.audioMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
kokoroURL := s.kokoroURL + "/v1/download/" + filename
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build proxy request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
http.Error(w, "kokoro download failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
||||
w.Header().Set("Content-Length", cl)
|
||||
}
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.oCfg
|
||||
cfg.SingleBookURL = "" // full catalogue
|
||||
|
||||
s.runAsync(w, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
|
||||
http.Error(w, `{"error":"request body must be JSON with \"url\" field"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := s.oCfg
|
||||
cfg.SingleBookURL = body.URL
|
||||
|
||||
s.runAsync(w, cfg)
|
||||
}
|
||||
|
||||
// runAsync launches an orchestrator in the background and returns 202 Accepted.
|
||||
// Only one scrape job runs at a time; concurrent requests receive 409 Conflict.
|
||||
func (s *Server) runAsync(w http.ResponseWriter, cfg orchestrator.Config) {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, `{"error":"a scrape job is already running"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
|
||||
defer cancel()
|
||||
|
||||
o := orchestrator.New(cfg, s.novel, s.log)
|
||||
if err := o.Run(ctx); err != nil {
|
||||
s.log.Error("scrape job failed", "err", fmt.Sprintf("%v", err))
|
||||
}
|
||||
}()
|
||||
return id
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
59
scraper/internal/storage/coverutil.go
Normal file
59
scraper/internal/storage/coverutil.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadAndStoreCover fetches the image at imageURL and stores it in the
|
||||
// store under key. Errors are logged but not returned — this is best-effort.
|
||||
// If the asset is already present the download is skipped.
|
||||
func DownloadAndStoreCover(store Store, log *slog.Logger, key, imageURL string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Skip if already stored.
|
||||
if _, _, ok, _ := store.GetBrowseAsset(ctx, key); ok {
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
log.Warn("cover: build request failed", "key", key, "url", imageURL, "err", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-scraper/1.0)")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Warn("cover: fetch failed", "key", key, "url", imageURL, "err", fmt.Errorf("%w", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Warn("cover: non-200 response", "key", key, "url", imageURL, "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Warn("cover: read body failed", "key", key, "url", imageURL, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
|
||||
if err := store.SaveBrowseAsset(ctx, key, data, contentType); err != nil {
|
||||
log.Warn("cover: SaveBrowseAsset failed", "key", key, "err", err)
|
||||
return
|
||||
}
|
||||
log.Debug("cover: stored", "key", key, "bytes", len(data))
|
||||
}
|
||||
488
scraper/internal/storage/hybrid.go
Normal file
488
scraper/internal/storage/hybrid.go
Normal file
@@ -0,0 +1,488 @@
|
||||
// hybrid.go implements the Store interface using PocketBase for structured data
|
||||
// and MinIO for binary chapter/audio blobs.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
// HybridStore satisfies Store by routing structured data to PocketBase and
|
||||
// binary objects (chapters, audio) to MinIO.
|
||||
type HybridStore struct {
|
||||
pb *PocketBaseStore
|
||||
minio *MinioClient
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewHybridStore constructs a HybridStore. It connects to both backends and
|
||||
// calls EnsureCollections to bootstrap any missing PocketBase collections.
|
||||
func NewHybridStore(ctx context.Context, pbCfg PocketBaseConfig, minioCfg MinioConfig, log *slog.Logger) (*HybridStore, error) {
|
||||
mc, err := NewMinioClient(ctx, minioCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: minio: %w", err)
|
||||
}
|
||||
pb := NewPocketBaseStore(pbCfg, log)
|
||||
// Verify PocketBase credentials before proceeding.
|
||||
if err := pb.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("storage: pocketbase auth: %w", err)
|
||||
}
|
||||
if err := pb.EnsureCollections(ctx); err != nil {
|
||||
// Non-fatal: 400/422 means collections already exist.
|
||||
log.Warn("EnsureCollections returned an error (may be safe to ignore)", "err", err)
|
||||
}
|
||||
if err := pb.EnsureMigrations(ctx); err != nil {
|
||||
log.Warn("EnsureMigrations returned an error", "err", err)
|
||||
}
|
||||
return &HybridStore{pb: pb, minio: mc, log: log}, nil
|
||||
}
|
||||
|
||||
// ─── Book metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) WriteMetadata(ctx context.Context, meta scraper.BookMeta) error {
|
||||
return h.pb.UpsertBook(ctx,
|
||||
meta.Slug, meta.Title, meta.Author, meta.Cover,
|
||||
meta.Status, meta.Summary, meta.SourceURL,
|
||||
meta.Genres, meta.TotalChapters, meta.Ranking,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *HybridStore) ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error) {
|
||||
rec, found, err := h.pb.GetBook(ctx, slug)
|
||||
if err != nil || !found {
|
||||
return scraper.BookMeta{}, found, err
|
||||
}
|
||||
return recToBookMeta(rec), true, nil
|
||||
}
|
||||
|
||||
func (h *HybridStore) ListBooks(ctx context.Context) ([]scraper.BookMeta, error) {
|
||||
rows, err := h.pb.ListBooks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
books := make([]scraper.BookMeta, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
books = append(books, recToBookMeta(r))
|
||||
}
|
||||
return books, nil
|
||||
}
|
||||
|
||||
func (h *HybridStore) LocalSlugs(ctx context.Context) (map[string]bool, error) {
|
||||
books, err := h.ListBooks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slugs := make(map[string]bool, len(books))
|
||||
for _, b := range books {
|
||||
slugs[b.Slug] = true
|
||||
}
|
||||
return slugs, nil
|
||||
}
|
||||
|
||||
func (h *HybridStore) MetadataMtime(ctx context.Context, slug string) int64 {
|
||||
t, err := h.pb.BookMetaUpdated(ctx, slug)
|
||||
if err != nil {
|
||||
h.log.Warn("MetadataMtime: BookMetaUpdated failed", "slug", slug, "err", err)
|
||||
return 0
|
||||
}
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
// ─── Chapters ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool {
|
||||
return h.minio.ChapterExists(ctx, slug, ref.Volume, ref.Number)
|
||||
}
|
||||
|
||||
func (h *HybridStore) WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error {
|
||||
content := "# " + chapter.Ref.Title + "\n\n" + chapter.Text + "\n"
|
||||
if err := h.minio.PutChapter(ctx, slug, chapter.Ref.Volume, chapter.Ref.Number, content); err != nil {
|
||||
return err
|
||||
}
|
||||
// Update chapter index in PocketBase.
|
||||
title, dateLabel := splitChapterTitle(chapter.Ref.Title)
|
||||
if err := h.pb.UpsertChapterIdx(ctx, slug, chapter.Ref.Number, title, dateLabel); err != nil {
|
||||
h.log.Warn("WriteChapter: failed to upsert chapter index in PocketBase",
|
||||
"slug", slug, "chapter", chapter.Ref.Number, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteChapterRefs upserts chapter index rows (number + title) for all refs
|
||||
// without writing any chapter text to MinIO. This pre-populates the chapter
|
||||
// list when a book is first seen via a live preview.
|
||||
func (h *HybridStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error {
|
||||
return h.pb.WriteChapterRefs(ctx, slug, refs)
|
||||
}
|
||||
|
||||
func (h *HybridStore) ReadChapter(ctx context.Context, slug string, n int) (string, error) {
|
||||
return h.minio.GetChapter(ctx, slug, 0, n)
|
||||
}
|
||||
|
||||
func (h *HybridStore) ListChapters(ctx context.Context, slug string) ([]ChapterInfo, error) {
|
||||
rows, err := h.pb.ListChapterIdx(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]ChapterInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
n := int(floatVal(r, "number"))
|
||||
title, _ := r["title"].(string)
|
||||
date, _ := r["date_label"].(string)
|
||||
infos = append(infos, ChapterInfo{Number: n, Title: title, Date: date})
|
||||
}
|
||||
sort.Slice(infos, func(i, j int) bool { return infos[i].Number < infos[j].Number })
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func (h *HybridStore) CountChapters(ctx context.Context, slug string) int {
|
||||
return h.pb.CountChapterIdx(ctx, slug)
|
||||
}
|
||||
|
||||
// ReindexChapters walks all MinIO objects for slug, reads the title from the
|
||||
// first line of each chapter markdown, and upserts them into chapters_idx.
|
||||
// This repairs the PocketBase index when it falls out of sync with MinIO.
|
||||
// Returns the number of chapters indexed and any non-fatal errors encountered.
|
||||
func (h *HybridStore) ReindexChapters(ctx context.Context, slug string) (int, error) {
|
||||
keys, err := h.minio.ListChapterKeys(ctx, slug)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reindex: list chapter keys: %w", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
var errs []string
|
||||
for _, key := range keys {
|
||||
// Parse chapter number from key: {slug}/vol-N/lo-hi/chapter-N.md
|
||||
n := chapterNumberFromKey(key)
|
||||
if n <= 0 {
|
||||
h.log.Warn("ReindexChapters: could not parse chapter number from key", "key", key)
|
||||
continue
|
||||
}
|
||||
|
||||
raw, readErr := h.minio.GetChapter(ctx, slug, 0, n)
|
||||
if readErr != nil {
|
||||
errs = append(errs, fmt.Sprintf("ch%d: %v", n, readErr))
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract title from first line ("# Title text") or fall back to empty.
|
||||
rawTitle := ""
|
||||
if line, _, found := strings.Cut(raw, "\n"); found || raw != "" {
|
||||
rawTitle = strings.TrimPrefix(strings.TrimSpace(line), "# ")
|
||||
}
|
||||
title, dateLabel := splitChapterTitle(rawTitle)
|
||||
|
||||
if upsertErr := h.pb.UpsertChapterIdx(ctx, slug, n, title, dateLabel); upsertErr != nil {
|
||||
errs = append(errs, fmt.Sprintf("ch%d upsert: %v", n, upsertErr))
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return count, fmt.Errorf("reindex: %d error(s): %s", len(errs), strings.Join(errs, "; "))
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// chapterNumberFromKey parses the chapter number from a MinIO object key of the
|
||||
// form "{slug}/vol-N/lo-hi/chapter-N.md".
|
||||
func chapterNumberFromKey(key string) int {
|
||||
// Grab the filename portion after the last '/'.
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) == 0 {
|
||||
return 0
|
||||
}
|
||||
filename := parts[len(parts)-1]
|
||||
// filename is "chapter-N.md"
|
||||
filename = strings.TrimSuffix(filename, ".md")
|
||||
filename = strings.TrimPrefix(filename, "chapter-")
|
||||
n, err := strconv.Atoi(filename)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ─── Ranking ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) WriteRankingItem(ctx context.Context, item RankingItem) error {
|
||||
return h.pb.UpsertRankingItem(ctx, item)
|
||||
}
|
||||
|
||||
func (h *HybridStore) ReadRankingItems(ctx context.Context) ([]RankingItem, error) {
|
||||
return h.pb.ListRankingItems(ctx)
|
||||
}
|
||||
|
||||
func (h *HybridStore) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) {
|
||||
last, err := h.pb.RankingLastUpdated(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if last.IsZero() {
|
||||
return false, nil
|
||||
}
|
||||
return time.Since(last) < maxAge, nil
|
||||
}
|
||||
|
||||
// ─── Audio cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool) {
|
||||
filename, ok, err := h.pb.GetAudioCache(ctx, cacheKey)
|
||||
if err != nil {
|
||||
h.log.Warn("GetAudioCache: PocketBase lookup failed", "cache_key", cacheKey, "err", err)
|
||||
}
|
||||
return filename, ok
|
||||
}
|
||||
|
||||
func (h *HybridStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error {
|
||||
return h.pb.SetAudioCache(ctx, cacheKey, filename)
|
||||
}
|
||||
|
||||
// ─── Reading progress ─────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool) {
|
||||
ch, updated, ok, err := h.pb.GetProgress(ctx, sessionID, slug)
|
||||
if err != nil {
|
||||
h.log.Warn("GetProgress: PocketBase lookup failed", "slug", slug, "err", err)
|
||||
return ReadingProgress{}, false
|
||||
}
|
||||
if !ok {
|
||||
return ReadingProgress{}, false
|
||||
}
|
||||
return ReadingProgress{Slug: slug, Chapter: ch, UpdatedAt: updated}, true
|
||||
}
|
||||
|
||||
func (h *HybridStore) SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error {
|
||||
return h.pb.SetProgress(ctx, sessionID, p.Slug, p.Chapter)
|
||||
}
|
||||
|
||||
func (h *HybridStore) AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error) {
|
||||
rows, err := h.pb.AllProgress(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ReadingProgress, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
slug, _ := r["slug"].(string)
|
||||
ch := int(floatVal(r, "chapter"))
|
||||
var updated time.Time
|
||||
if ts, ok := r["updated"].(string); ok {
|
||||
updated, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
out = append(out, ReadingProgress{Slug: slug, Chapter: ch, UpdatedAt: updated})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *HybridStore) DeleteProgress(ctx context.Context, sessionID, slug string) error {
|
||||
return h.pb.DeleteProgress(ctx, sessionID, slug)
|
||||
}
|
||||
|
||||
// ─── AudioObjectKey ───────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return AudioObjectKey(slug, n, voice)
|
||||
}
|
||||
|
||||
func (h *HybridStore) AudioExists(ctx context.Context, key string) bool {
|
||||
return h.minio.AudioExists(ctx, key)
|
||||
}
|
||||
|
||||
// ─── PutAudio ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) PutAudio(ctx context.Context, key string, data []byte) error {
|
||||
return h.minio.PutAudio(ctx, key, data)
|
||||
}
|
||||
|
||||
// ─── Presigned URLs ───────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
|
||||
return h.minio.PresignChapter(ctx, slug, 0, n, expires)
|
||||
}
|
||||
|
||||
func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
|
||||
return h.minio.PresignAudio(ctx, key, expires)
|
||||
}
|
||||
|
||||
// ─── Browse page snapshots ────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {
|
||||
return h.minio.PutBrowsePage(ctx, key, html)
|
||||
}
|
||||
|
||||
func (h *HybridStore) GetBrowsePage(ctx context.Context, key string) (string, bool, error) {
|
||||
return h.minio.GetBrowsePage(ctx, key)
|
||||
}
|
||||
|
||||
func (h *HybridStore) BrowseHTMLKey(domain string, page int) string {
|
||||
return BrowseHTMLKey(domain, page)
|
||||
}
|
||||
|
||||
func (h *HybridStore) BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string {
|
||||
return BrowseFilteredHTMLKey(domain, page, sort, genre, status)
|
||||
}
|
||||
|
||||
func (h *HybridStore) BrowseCoverKey(domain, slug string) string {
|
||||
return BrowseCoverKey(domain, slug)
|
||||
}
|
||||
|
||||
func (h *HybridStore) SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error {
|
||||
return h.minio.PutBrowseAsset(ctx, key, data, contentType)
|
||||
}
|
||||
|
||||
func (h *HybridStore) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) {
|
||||
return h.minio.GetBrowseAsset(ctx, key)
|
||||
}
|
||||
|
||||
// ─── Scraping tasks ───────────────────────────────────────────────────────────
|
||||
|
||||
func (h *HybridStore) CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error) {
|
||||
return h.pb.CreateScrapingTask(ctx, kind, targetURL)
|
||||
}
|
||||
|
||||
func (h *HybridStore) UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error {
|
||||
data := map[string]interface{}{
|
||||
"status": u.Status,
|
||||
"books_found": u.BooksFound,
|
||||
"chapters_scraped": u.ChaptersScraped,
|
||||
"chapters_skipped": u.ChaptersSkipped,
|
||||
"errors": u.Errors,
|
||||
"error_message": u.ErrorMessage,
|
||||
}
|
||||
if !u.Finished.IsZero() {
|
||||
data["finished"] = u.Finished.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return h.pb.UpdateScrapingTask(ctx, id, data)
|
||||
}
|
||||
|
||||
func (h *HybridStore) ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error) {
|
||||
rows, err := h.pb.ListScrapingTasks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]ScrapeTask, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := ScrapeTask{
|
||||
ID: strVal(r, "id"),
|
||||
Kind: strVal(r, "kind"),
|
||||
TargetURL: strVal(r, "target_url"),
|
||||
Status: strVal(r, "status"),
|
||||
BooksFound: int(floatVal(r, "books_found")),
|
||||
ChaptersScraped: int(floatVal(r, "chapters_scraped")),
|
||||
ChaptersSkipped: int(floatVal(r, "chapters_skipped")),
|
||||
Errors: int(floatVal(r, "errors")),
|
||||
ErrorMessage: strVal(r, "error_message"),
|
||||
}
|
||||
if ts, ok := r["started"].(string); ok {
|
||||
t.Started, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
if ts, ok := r["finished"].(string); ok && ts != "" {
|
||||
t.Finished, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func recToBookMeta(rec map[string]interface{}) scraper.BookMeta {
|
||||
m := scraper.BookMeta{
|
||||
Slug: strVal(rec, "slug"),
|
||||
Title: strVal(rec, "title"),
|
||||
Author: strVal(rec, "author"),
|
||||
Cover: strVal(rec, "cover"),
|
||||
Status: strVal(rec, "status"),
|
||||
Summary: strVal(rec, "summary"),
|
||||
SourceURL: strVal(rec, "source_url"),
|
||||
}
|
||||
if tc := floatVal(rec, "total_chapters"); tc > 0 {
|
||||
m.TotalChapters = int(tc)
|
||||
}
|
||||
if rk := floatVal(rec, "ranking"); rk > 0 {
|
||||
m.Ranking = int(rk)
|
||||
}
|
||||
// Genres stored as JSON string or array.
|
||||
switch v := rec["genres"].(type) {
|
||||
case string:
|
||||
_ = json.Unmarshal([]byte(v), &m.Genres)
|
||||
case []interface{}:
|
||||
for _, g := range v {
|
||||
if s, ok := g.(string); ok {
|
||||
m.Genres = append(m.Genres, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func strVal(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// splitChapterTitle mirrors writer.SplitChapterTitle logic (simplified).
|
||||
func splitChapterTitle(raw string) (title, date string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
// Strip leading numeric index.
|
||||
if idx := strings.IndexFunc(raw, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 {
|
||||
prefix := raw[:idx]
|
||||
allDigit := true
|
||||
for _, c := range prefix {
|
||||
if c < '0' || c > '9' {
|
||||
allDigit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigit {
|
||||
raw = strings.TrimSpace(raw[idx:])
|
||||
}
|
||||
}
|
||||
// Detect trailing relative date. Build a flat list of all suffixes once
|
||||
// to avoid a double-nested loop.
|
||||
units := []string{"second", "minute", "hour", "day", "week", "month", "year"}
|
||||
suffixes := make([]string, 0, len(units)*2)
|
||||
for _, u := range units {
|
||||
suffixes = append(suffixes, u+"s ago", u+" ago")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
for _, suffix := range suffixes {
|
||||
idx := strings.LastIndex(lower, suffix)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
// Find start of the numeric token that precedes the unit.
|
||||
// Strip any whitespace that separates the number from the unit so
|
||||
// that LastIndex finds the space before the digit, not the one
|
||||
// between the digit and the unit word.
|
||||
before := strings.TrimRight(raw[:idx], " \t")
|
||||
start := strings.LastIndex(before, " ")
|
||||
if start < 0 {
|
||||
start = 0
|
||||
} else {
|
||||
start++ // advance past the space to point at the digit
|
||||
}
|
||||
numPart := strings.TrimSpace(raw[start:idx])
|
||||
fields := strings.Fields(numPart)
|
||||
if len(fields) > 0 {
|
||||
if _, err := strconv.Atoi(fields[0]); err == nil {
|
||||
return strings.TrimSpace(raw[:start]), strings.TrimSpace(raw[start : idx+len(suffix)])
|
||||
}
|
||||
}
|
||||
}
|
||||
return raw, ""
|
||||
}
|
||||
473
scraper/internal/storage/hybrid_integration_test.go
Normal file
473
scraper/internal/storage/hybrid_integration_test.go
Normal file
@@ -0,0 +1,473 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests for HybridStore (PocketBase + MinIO) end-to-end.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// MINIO_ENDPOINT=localhost:9000 \
|
||||
// POCKETBASE_URL=http://localhost:8090 \
|
||||
// go test -v -tags integration -timeout 120s \
|
||||
// github.com/libnovel/scraper/internal/storage
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
// newTestHybridStore constructs a HybridStore from environment variables.
|
||||
// Skips the test if either MINIO_ENDPOINT or POCKETBASE_URL is unset.
|
||||
func newTestHybridStore(t *testing.T) *HybridStore {
|
||||
t.Helper()
|
||||
if ep := envOr("MINIO_ENDPOINT", ""); ep == "" {
|
||||
t.Skip("MINIO_ENDPOINT not set — skipping HybridStore integration test")
|
||||
}
|
||||
if u := envOr("POCKETBASE_URL", ""); u == "" {
|
||||
t.Skip("POCKETBASE_URL not set — skipping HybridStore integration test")
|
||||
}
|
||||
|
||||
pbCfg := PocketBaseConfig{
|
||||
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
||||
AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"),
|
||||
AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"),
|
||||
}
|
||||
minioCfg := MinioConfig{
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: envOr("MINIO_USE_SSL", "false") == "true",
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
hs, err := NewHybridStore(ctx, pbCfg, minioCfg, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewHybridStore: %v", err)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestHybridStore_WriteReadMetadata exercises WriteMetadata → ReadMetadata round-trip.
|
||||
func TestHybridStore_WriteReadMetadata(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
slug := testSlug(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug))
|
||||
})
|
||||
|
||||
meta := scraper.BookMeta{
|
||||
Slug: slug,
|
||||
Title: "Hybrid Store Test Novel",
|
||||
Author: "Test Author",
|
||||
Cover: "https://example.com/cover.jpg",
|
||||
Status: "Ongoing",
|
||||
Genres: []string{"Fantasy", "Action"},
|
||||
Summary: "A novel for integration testing.",
|
||||
TotalChapters: 99,
|
||||
SourceURL: fmt.Sprintf("https://example.com/book/%s", slug),
|
||||
Ranking: 5,
|
||||
}
|
||||
|
||||
t.Run("WriteMetadata", func(t *testing.T) {
|
||||
if err := hs.WriteMetadata(ctx, meta); err != nil {
|
||||
t.Fatalf("WriteMetadata: %v", err)
|
||||
}
|
||||
t.Logf("wrote metadata for slug=%q", slug)
|
||||
})
|
||||
|
||||
t.Run("ReadMetadata", func(t *testing.T) {
|
||||
got, found, err := hs.ReadMetadata(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMetadata: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("ReadMetadata: not found after WriteMetadata")
|
||||
}
|
||||
t.Logf("read: %+v", got)
|
||||
if got.Title != meta.Title {
|
||||
t.Errorf("Title = %q, want %q", got.Title, meta.Title)
|
||||
}
|
||||
if got.Author != meta.Author {
|
||||
t.Errorf("Author = %q, want %q", got.Author, meta.Author)
|
||||
}
|
||||
if got.TotalChapters != meta.TotalChapters {
|
||||
t.Errorf("TotalChapters = %d, want %d", got.TotalChapters, meta.TotalChapters)
|
||||
}
|
||||
if got.Ranking != meta.Ranking {
|
||||
t.Errorf("Ranking = %d, want %d", got.Ranking, meta.Ranking)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MetadataMtime", func(t *testing.T) {
|
||||
mtime := hs.MetadataMtime(ctx, slug)
|
||||
if mtime == 0 {
|
||||
t.Error("MetadataMtime returned 0")
|
||||
}
|
||||
t.Logf("mtime: %d (%s)", mtime, time.Unix(mtime, 0))
|
||||
})
|
||||
|
||||
t.Run("ReadMetadata_NotFound", func(t *testing.T) {
|
||||
_, found, err := hs.ReadMetadata(ctx, "this-slug-does-not-exist-xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMetadata (miss): %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("ReadMetadata returned found=true for a non-existent slug")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHybridStore_WriteReadChapter exercises WriteChapter (MinIO blob + PocketBase
|
||||
// index), ReadChapter, CountChapters, and ListChapters.
|
||||
func TestHybridStore_WriteReadChapter(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
slug := testSlug(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug))
|
||||
// MinIO objects are not cleaned up — they use the test slug as prefix
|
||||
// and are effectively isolated.
|
||||
})
|
||||
|
||||
chapters := []scraper.Chapter{
|
||||
{
|
||||
Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: The Beginning", Volume: 0},
|
||||
Text: "The first chapter text with enough content to be meaningful for a real novel chapter.",
|
||||
},
|
||||
{
|
||||
Ref: scraper.ChapterRef{Number: 2, Title: "Chapter 2: Rising Action", Volume: 0},
|
||||
Text: "The second chapter text continues the story from where the first left off.",
|
||||
},
|
||||
{
|
||||
Ref: scraper.ChapterRef{Number: 3, Title: "Chapter 3: Climax", Volume: 0},
|
||||
Text: "The third chapter text reaches the peak of tension and conflict.",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("WriteChapter", func(t *testing.T) {
|
||||
for _, ch := range chapters {
|
||||
if err := hs.WriteChapter(ctx, slug, ch); err != nil {
|
||||
t.Fatalf("WriteChapter(%d): %v", ch.Ref.Number, err)
|
||||
}
|
||||
t.Logf("wrote chapter %d", ch.Ref.Number)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ChapterExists", func(t *testing.T) {
|
||||
for _, ch := range chapters {
|
||||
if !hs.ChapterExists(ctx, slug, ch.Ref) {
|
||||
t.Errorf("ChapterExists(chapter %d) = false after WriteChapter", ch.Ref.Number)
|
||||
}
|
||||
}
|
||||
missing := scraper.ChapterRef{Number: 999, Volume: 0}
|
||||
if hs.ChapterExists(ctx, slug, missing) {
|
||||
t.Error("ChapterExists(999) = true for a chapter that was never written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadChapter", func(t *testing.T) {
|
||||
for _, ch := range chapters {
|
||||
got, err := hs.ReadChapter(ctx, slug, ch.Ref.Number)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadChapter(%d): %v", ch.Ref.Number, err)
|
||||
}
|
||||
// WriteChapter prepends "# <title>\n\n" and appends "\n".
|
||||
expectedPrefix := "# " + ch.Ref.Title
|
||||
if !strings.HasPrefix(got, expectedPrefix) {
|
||||
t.Errorf("chapter %d: content doesn't start with expected header\ngot: %q\nwant prefix: %q",
|
||||
ch.Ref.Number, got[:min(len(got), 80)], expectedPrefix)
|
||||
}
|
||||
if !strings.Contains(got, ch.Text) {
|
||||
t.Errorf("chapter %d: content doesn't contain original text", ch.Ref.Number)
|
||||
}
|
||||
t.Logf("chapter %d: %d bytes", ch.Ref.Number, len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CountChapters", func(t *testing.T) {
|
||||
count := hs.CountChapters(ctx, slug)
|
||||
if count != len(chapters) {
|
||||
t.Errorf("CountChapters = %d, want %d", count, len(chapters))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListChapters", func(t *testing.T) {
|
||||
infos, err := hs.ListChapters(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapters: %v", err)
|
||||
}
|
||||
if len(infos) != len(chapters) {
|
||||
t.Errorf("ListChapters returned %d entries, want %d", len(infos), len(chapters))
|
||||
}
|
||||
for i, info := range infos {
|
||||
t.Logf("infos[%d]: number=%d title=%q date=%q", i, info.Number, info.Title, info.Date)
|
||||
}
|
||||
// Verify sorted order.
|
||||
for i := 1; i < len(infos); i++ {
|
||||
if infos[i].Number <= infos[i-1].Number {
|
||||
t.Errorf("ListChapters not sorted: infos[%d].Number=%d <= infos[%d].Number=%d",
|
||||
i, infos[i].Number, i-1, infos[i-1].Number)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHybridStore_WriteReadRanking exercises WriteRankingItem → ReadRankingItems
|
||||
// round-trip and RankingFreshEnough.
|
||||
func TestHybridStore_WriteReadRanking(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
slug1 := "integ-rank-1-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
slug2 := "integ-rank-2-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
slug3 := "integ-rank-3-" + fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for _, sl := range []string{slug1, slug2, slug3} {
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl))
|
||||
}
|
||||
})
|
||||
|
||||
items := []RankingItem{
|
||||
{Rank: 1, Slug: slug1, Title: "Top Novel", Author: "Author A", Status: "Ongoing", SourceURL: "https://example.com/book/top"},
|
||||
{Rank: 2, Slug: slug2, Title: "Second Novel", Author: "Author B", Genres: []string{"Action"}, Status: "Completed"},
|
||||
{Rank: 3, Slug: slug3, Title: "Third Novel"},
|
||||
}
|
||||
|
||||
t.Run("WriteRankingItem", func(t *testing.T) {
|
||||
for _, item := range items {
|
||||
if err := hs.WriteRankingItem(ctx, item); err != nil {
|
||||
t.Fatalf("WriteRankingItem(%s): %v", item.Slug, err)
|
||||
}
|
||||
}
|
||||
t.Logf("wrote %d ranking items", len(items))
|
||||
})
|
||||
|
||||
t.Run("ReadRankingItems", func(t *testing.T) {
|
||||
got, err := hs.ReadRankingItems(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRankingItems: %v", err)
|
||||
}
|
||||
// Filter to just our test slugs (other tests may leave rows).
|
||||
var ours []RankingItem
|
||||
slugSet := map[string]bool{slug1: true, slug2: true, slug3: true}
|
||||
for _, g := range got {
|
||||
if slugSet[g.Slug] {
|
||||
ours = append(ours, g)
|
||||
}
|
||||
}
|
||||
if len(ours) != 3 {
|
||||
t.Fatalf("ReadRankingItems returned %d test items, want 3", len(ours))
|
||||
}
|
||||
// Verify order by rank.
|
||||
for i := 1; i < len(ours); i++ {
|
||||
if ours[i].Rank <= ours[i-1].Rank {
|
||||
t.Errorf("items not sorted by rank: ours[%d].Rank=%d, ours[%d].Rank=%d",
|
||||
i, ours[i].Rank, i-1, ours[i-1].Rank)
|
||||
}
|
||||
}
|
||||
// Verify fields.
|
||||
if ours[0].Title != "Top Novel" {
|
||||
t.Errorf("ours[0].Title = %q, want %q", ours[0].Title, "Top Novel")
|
||||
}
|
||||
if ours[0].Author != "Author A" {
|
||||
t.Errorf("ours[0].Author = %q, want %q", ours[0].Author, "Author A")
|
||||
}
|
||||
t.Logf("ranking items: %+v", ours)
|
||||
})
|
||||
|
||||
t.Run("RankingFreshEnough", func(t *testing.T) {
|
||||
fresh, err := hs.RankingFreshEnough(ctx, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("RankingFreshEnough: %v", err)
|
||||
}
|
||||
if !fresh {
|
||||
t.Error("RankingFreshEnough(24h) returned false immediately after writing items")
|
||||
}
|
||||
t.Logf("ranking fresh=true")
|
||||
})
|
||||
}
|
||||
|
||||
// TestHybridStore_Progress exercises SetProgress → GetProgress → AllProgress →
|
||||
// DeleteProgress via the HybridStore.
|
||||
func TestHybridStore_Progress(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
slug := testSlug(t)
|
||||
const sessionID = "hybrid-test-session-abc"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "progress",
|
||||
fmt.Sprintf(`session_id="%s"`, sessionID))
|
||||
})
|
||||
|
||||
p := ReadingProgress{Slug: slug, Chapter: 7, UpdatedAt: time.Now()}
|
||||
|
||||
t.Run("SetProgress", func(t *testing.T) {
|
||||
if err := hs.SetProgress(ctx, sessionID, p); err != nil {
|
||||
t.Fatalf("SetProgress: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetProgress", func(t *testing.T) {
|
||||
got, ok := hs.GetProgress(ctx, sessionID, slug)
|
||||
if !ok {
|
||||
t.Fatal("GetProgress: not found after SetProgress")
|
||||
}
|
||||
if got.Chapter != 7 {
|
||||
t.Errorf("Chapter = %d, want 7", got.Chapter)
|
||||
}
|
||||
if got.Slug != slug {
|
||||
t.Errorf("Slug = %q, want %q", got.Slug, slug)
|
||||
}
|
||||
t.Logf("progress: chapter=%d slug=%q updated=%s", got.Chapter, got.Slug, got.UpdatedAt)
|
||||
})
|
||||
|
||||
t.Run("AllProgress", func(t *testing.T) {
|
||||
all, err := hs.AllProgress(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("AllProgress: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, item := range all {
|
||||
if item.Slug == slug {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("AllProgress did not contain slug %q (total=%d)", slug, len(all))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteProgress", func(t *testing.T) {
|
||||
if err := hs.DeleteProgress(ctx, sessionID, slug); err != nil {
|
||||
t.Fatalf("DeleteProgress: %v", err)
|
||||
}
|
||||
_, ok := hs.GetProgress(ctx, sessionID, slug)
|
||||
if ok {
|
||||
t.Error("GetProgress returned ok=true after DeleteProgress")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHybridStore_PresignChapter writes a chapter to MinIO via HybridStore,
|
||||
// then calls PresignChapter and verifies a non-empty URL is returned.
|
||||
func TestHybridStore_PresignChapter(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
slug := testSlug(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := scraper.Chapter{
|
||||
Ref: scraper.ChapterRef{Number: 1, Title: "Chapter 1: Presign Test", Volume: 0},
|
||||
Text: "Text for the presign chapter test.",
|
||||
}
|
||||
|
||||
if err := hs.WriteChapter(ctx, slug, ch); err != nil {
|
||||
t.Fatalf("WriteChapter: %v", err)
|
||||
}
|
||||
|
||||
url, err := hs.PresignChapter(ctx, slug, 1, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignChapter: %v", err)
|
||||
}
|
||||
if url == "" {
|
||||
t.Fatal("PresignChapter returned empty URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "http") {
|
||||
t.Errorf("PresignChapter URL does not start with http: %q", url)
|
||||
}
|
||||
t.Logf("presigned chapter URL: %s", url)
|
||||
}
|
||||
|
||||
// TestHybridStore_PresignAudio puts a fake audio blob into MinIO via the
|
||||
// underlying MinioClient and verifies PresignAudio returns a valid URL.
|
||||
func TestHybridStore_PresignAudio(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
slug := testSlug(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key := hs.AudioObjectKey(slug, 1, "af_bella")
|
||||
fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00hybrid-presign-audio-test")
|
||||
|
||||
if err := hs.minio.PutAudio(ctx, key, fakeAudio); err != nil {
|
||||
t.Fatalf("PutAudio: %v", err)
|
||||
}
|
||||
|
||||
url, err := hs.PresignAudio(ctx, key, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignAudio: %v", err)
|
||||
}
|
||||
if url == "" {
|
||||
t.Fatal("PresignAudio returned empty URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "http") {
|
||||
t.Errorf("PresignAudio URL does not start with http: %q", url)
|
||||
}
|
||||
t.Logf("presigned audio URL: %s", url)
|
||||
}
|
||||
|
||||
// TestHybridStore_AudioCache exercises SetAudioCache → GetAudioCache via HybridStore.
|
||||
func TestHybridStore_AudioCache(t *testing.T) {
|
||||
hs := newTestHybridStore(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cacheKey := fmt.Sprintf("hybrid-audio-test-%d", time.Now().UnixMilli())
|
||||
const filename = "speech_hybrid123.mp3"
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "audio_cache",
|
||||
fmt.Sprintf(`cache_key="%s"`, cacheKey))
|
||||
})
|
||||
|
||||
if err := hs.SetAudioCache(ctx, cacheKey, filename); err != nil {
|
||||
t.Fatalf("SetAudioCache: %v", err)
|
||||
}
|
||||
|
||||
got, ok := hs.GetAudioCache(ctx, cacheKey)
|
||||
if !ok {
|
||||
t.Fatal("GetAudioCache returned ok=false after SetAudioCache")
|
||||
}
|
||||
if got != filename {
|
||||
t.Errorf("filename = %q, want %q", got, filename)
|
||||
}
|
||||
t.Logf("audio cache: cacheKey=%q filename=%q", cacheKey, got)
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
77
scraper/internal/storage/hybrid_unit_test.go
Normal file
77
scraper/internal/storage/hybrid_unit_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── chapterNumberFromKey ──────────────────────────────────────────────────────
|
||||
|
||||
func TestChapterNumberFromKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
want int
|
||||
}{
|
||||
// Standard four-segment key.
|
||||
{"my-novel/vol-0/1-50/chapter-1.md", 1},
|
||||
{"my-novel/vol-0/1-50/chapter-42.md", 42},
|
||||
{"my-novel/vol-0/51-100/chapter-99.md", 99},
|
||||
// Large chapter numbers.
|
||||
{"some-novel/vol-1/1001-1050/chapter-1024.md", 1024},
|
||||
// Nested deeper paths should still work (last segment used).
|
||||
{"a/b/c/d/chapter-7.md", 7},
|
||||
// Malformed / unexpected inputs — should return 0 without panicking.
|
||||
{"chapter-notanumber.md", 0},
|
||||
{"", 0},
|
||||
// No .md extension — TrimSuffix is a no-op; TrimPrefix still strips
|
||||
// "chapter-", so the number is parsed successfully.
|
||||
{"no-md-extension/chapter-5", 5},
|
||||
{"my-novel/vol-0/1-50/chapter-0.md", 0}, // 0 is invalid (chapters are 1-based)
|
||||
{"my-novel/vol-0/1-50/chapter--1.md", 0},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := chapterNumberFromKey(tc.key)
|
||||
if got != tc.want {
|
||||
t.Errorf("chapterNumberFromKey(%q) = %d, want %d", tc.key, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── splitChapterTitle ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestSplitChapterTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
wantTitle string
|
||||
wantDate string
|
||||
}{
|
||||
// No date — title is returned as-is.
|
||||
{"The Great Battle", "The Great Battle", ""},
|
||||
// Leading numeric index is stripped.
|
||||
{"42 The Great Battle", "The Great Battle", ""},
|
||||
// Relative date with plural unit.
|
||||
{"The Storm Arrives 3 days ago", "The Storm Arrives", "3 days ago"},
|
||||
// Singular unit.
|
||||
{"A New Hope 1 week ago", "A New Hope", "1 week ago"},
|
||||
// Minutes and seconds.
|
||||
{"Flash Fight 5 minutes ago", "Flash Fight", "5 minutes ago"},
|
||||
{"Quick Strike 30 seconds ago", "Quick Strike", "30 seconds ago"},
|
||||
// Months and years.
|
||||
{"Old Chapter 2 months ago", "Old Chapter", "2 months ago"},
|
||||
{"Ancient Story 1 year ago", "Ancient Story", "1 year ago"},
|
||||
// Leading index AND trailing date.
|
||||
{"5 The Final Chapter 2 hours ago", "The Final Chapter", "2 hours ago"},
|
||||
// Extra whitespace.
|
||||
{" The Calm ", "The Calm", ""},
|
||||
// Empty string.
|
||||
{"", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
title, date := splitChapterTitle(tc.raw)
|
||||
if title != tc.wantTitle || date != tc.wantDate {
|
||||
t.Errorf("splitChapterTitle(%q) = (%q, %q), want (%q, %q)",
|
||||
tc.raw, title, date, tc.wantTitle, tc.wantDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
655
scraper/internal/storage/integration_test.go
Normal file
655
scraper/internal/storage/integration_test.go
Normal file
@@ -0,0 +1,655 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests for MinioClient and PocketBaseStore against live instances.
|
||||
//
|
||||
// These tests require running MinIO and PocketBase services. They are gated
|
||||
// behind the "integration" build tag and are never run in a normal `go test ./...`.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// MINIO_ENDPOINT=localhost:9000 \
|
||||
// POCKETBASE_URL=http://localhost:8090 \
|
||||
// go test -v -tags integration -timeout 120s \
|
||||
// github.com/libnovel/scraper/internal/storage
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func newTestMinioClient(t *testing.T) *MinioClient {
|
||||
t.Helper()
|
||||
endpoint := os.Getenv("MINIO_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
t.Skip("MINIO_ENDPOINT not set — skipping MinIO integration test")
|
||||
}
|
||||
useSSL := os.Getenv("MINIO_USE_SSL") == "true"
|
||||
cfg := MinioConfig{
|
||||
Endpoint: endpoint,
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: useSSL,
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
mc, err := NewMinioClient(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMinioClient: %v", err)
|
||||
}
|
||||
return mc
|
||||
}
|
||||
|
||||
func newTestPocketBaseStore(t *testing.T) *PocketBaseStore {
|
||||
t.Helper()
|
||||
pbURL := os.Getenv("POCKETBASE_URL")
|
||||
if pbURL == "" {
|
||||
t.Skip("POCKETBASE_URL not set — skipping PocketBase integration test")
|
||||
}
|
||||
cfg := PocketBaseConfig{
|
||||
BaseURL: pbURL,
|
||||
AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"),
|
||||
AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"),
|
||||
}
|
||||
store := NewPocketBaseStore(cfg, slog.Default())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := store.EnsureCollections(ctx); err != nil {
|
||||
t.Logf("EnsureCollections (may be harmless): %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// testSlug generates a unique test slug to avoid collisions between parallel runs.
|
||||
func testSlug(t *testing.T) string {
|
||||
t.Helper()
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, strings.ToLower(t.Name()))
|
||||
// Truncate and append a timestamp to keep it unique.
|
||||
if len(safe) > 30 {
|
||||
safe = safe[:30]
|
||||
}
|
||||
return fmt.Sprintf("test-%s-%d", safe, time.Now().UnixMilli()%100000)
|
||||
}
|
||||
|
||||
// ─── MinioClient tests ────────────────────────────────────────────────────────
|
||||
|
||||
// TestMinioClient_ChapterRoundTrip verifies PutChapter → GetChapter →
|
||||
// ChapterExists → ListChapterKeys for a single chapter.
|
||||
func TestMinioClient_ChapterRoundTrip(t *testing.T) {
|
||||
mc := newTestMinioClient(t)
|
||||
slug := testSlug(t)
|
||||
const vol = 0
|
||||
const n = 1
|
||||
content := "# Chapter 1\n\nHello integration world.\n"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Run("PutChapter", func(t *testing.T) {
|
||||
if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil {
|
||||
t.Fatalf("PutChapter: %v", err)
|
||||
}
|
||||
t.Logf("stored chapter at key: %s", chapterKey(slug, vol, n))
|
||||
})
|
||||
|
||||
t.Run("GetChapter", func(t *testing.T) {
|
||||
got, err := mc.GetChapter(ctx, slug, vol, n)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChapter: %v", err)
|
||||
}
|
||||
if got != content {
|
||||
t.Errorf("GetChapter round-trip mismatch:\ngot: %q\nwant: %q", got, content)
|
||||
}
|
||||
t.Logf("retrieved %d bytes", len(got))
|
||||
})
|
||||
|
||||
t.Run("ChapterExists", func(t *testing.T) {
|
||||
if !mc.ChapterExists(ctx, slug, vol, n) {
|
||||
t.Error("ChapterExists returned false for a just-stored chapter")
|
||||
}
|
||||
if mc.ChapterExists(ctx, slug, vol, 999) {
|
||||
t.Error("ChapterExists returned true for a chapter that was never stored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListChapterKeys", func(t *testing.T) {
|
||||
keys, err := mc.ListChapterKeys(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapterKeys: %v", err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("ListChapterKeys returned %d keys, want 1: %v", len(keys), keys)
|
||||
}
|
||||
expectedKey := chapterKey(slug, vol, n)
|
||||
if keys[0] != expectedKey {
|
||||
t.Errorf("key = %q, want %q", keys[0], expectedKey)
|
||||
}
|
||||
t.Logf("keys: %v", keys)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMinioClient_MultiChapterList stores several chapters and verifies
|
||||
// ListChapterKeys returns them all.
|
||||
func TestMinioClient_MultiChapterList(t *testing.T) {
|
||||
mc := newTestMinioClient(t)
|
||||
slug := testSlug(t)
|
||||
const vol = 0
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Store chapters 1, 2, 51 (crosses the 1-50 folder boundary).
|
||||
chapters := []int{1, 2, 51}
|
||||
for _, n := range chapters {
|
||||
content := fmt.Sprintf("# Chapter %d\n\nContent for chapter %d.\n", n, n)
|
||||
if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil {
|
||||
t.Fatalf("PutChapter(%d): %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
keys, err := mc.ListChapterKeys(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapterKeys: %v", err)
|
||||
}
|
||||
t.Logf("keys: %v", keys)
|
||||
if len(keys) != len(chapters) {
|
||||
t.Errorf("ListChapterKeys returned %d keys, want %d", len(keys), len(chapters))
|
||||
}
|
||||
|
||||
count := mc.CountChapters(ctx, slug)
|
||||
if count != len(chapters) {
|
||||
t.Errorf("CountChapters = %d, want %d", count, len(chapters))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinioClient_PresignChapter verifies PresignChapter returns a non-empty URL.
|
||||
func TestMinioClient_PresignChapter(t *testing.T) {
|
||||
mc := newTestMinioClient(t)
|
||||
slug := testSlug(t)
|
||||
const vol = 0
|
||||
const n = 1
|
||||
content := "# Presign test\n\nSome content.\n"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := mc.PutChapter(ctx, slug, vol, n, content); err != nil {
|
||||
t.Fatalf("PutChapter: %v", err)
|
||||
}
|
||||
|
||||
url, err := mc.PresignChapter(ctx, slug, vol, n, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignChapter: %v", err)
|
||||
}
|
||||
if url == "" {
|
||||
t.Fatal("PresignChapter returned empty URL")
|
||||
}
|
||||
t.Logf("presigned URL: %s", url)
|
||||
|
||||
// URL must be an http(s) URL and contain the slug somewhere.
|
||||
if !strings.HasPrefix(url, "http") {
|
||||
t.Errorf("URL does not start with http: %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinioClient_AudioRoundTrip verifies PutAudio → GetAudio → AudioExists.
|
||||
func TestMinioClient_AudioRoundTrip(t *testing.T) {
|
||||
mc := newTestMinioClient(t)
|
||||
slug := testSlug(t)
|
||||
key := AudioObjectKey(slug, 1, "af_bella")
|
||||
|
||||
// Use minimal fake MP3 bytes (just a recognisable prefix).
|
||||
fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00integration-test-audio")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Run("PutAudio", func(t *testing.T) {
|
||||
if err := mc.PutAudio(ctx, key, fakeAudio); err != nil {
|
||||
t.Fatalf("PutAudio: %v", err)
|
||||
}
|
||||
t.Logf("stored audio at key: %s", key)
|
||||
})
|
||||
|
||||
t.Run("GetAudio", func(t *testing.T) {
|
||||
got, err := mc.GetAudio(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAudio: %v", err)
|
||||
}
|
||||
if string(got) != string(fakeAudio) {
|
||||
t.Errorf("GetAudio round-trip mismatch: got %d bytes, want %d", len(got), len(fakeAudio))
|
||||
}
|
||||
t.Logf("retrieved %d bytes", len(got))
|
||||
})
|
||||
|
||||
t.Run("AudioExists", func(t *testing.T) {
|
||||
if !mc.AudioExists(ctx, key) {
|
||||
t.Error("AudioExists returned false for a just-stored audio object")
|
||||
}
|
||||
if mc.AudioExists(ctx, "nonexistent/key.mp3") {
|
||||
t.Error("AudioExists returned true for a key that was never stored")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMinioClient_PresignAudio verifies PresignAudio returns a non-empty URL.
|
||||
func TestMinioClient_PresignAudio(t *testing.T) {
|
||||
mc := newTestMinioClient(t)
|
||||
slug := testSlug(t)
|
||||
key := AudioObjectKey(slug, 1, "af_bella")
|
||||
fakeAudio := []byte("ID3\x03\x00\x00\x00\x00\x00\x00presign-audio-test")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := mc.PutAudio(ctx, key, fakeAudio); err != nil {
|
||||
t.Fatalf("PutAudio: %v", err)
|
||||
}
|
||||
|
||||
url, err := mc.PresignAudio(ctx, key, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("PresignAudio: %v", err)
|
||||
}
|
||||
if url == "" {
|
||||
t.Fatal("PresignAudio returned empty URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "http") {
|
||||
t.Errorf("URL does not start with http: %q", url)
|
||||
}
|
||||
t.Logf("presigned audio URL: %s", url)
|
||||
}
|
||||
|
||||
// ─── PocketBaseStore tests ────────────────────────────────────────────────────
|
||||
|
||||
// TestPocketBaseStore_Ping verifies that admin auth works.
|
||||
func TestPocketBaseStore_Ping(t *testing.T) {
|
||||
store := newTestPocketBaseStore(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := store.Ping(ctx); err != nil {
|
||||
t.Fatalf("Ping: %v", err)
|
||||
}
|
||||
t.Log("Ping succeeded")
|
||||
}
|
||||
|
||||
// TestPocketBaseStore_BookRoundTrip tests UpsertBook → GetBook → ListBooks →
|
||||
// BookMetaUpdated.
|
||||
func TestPocketBaseStore_BookRoundTrip(t *testing.T) {
|
||||
store := newTestPocketBaseStore(t)
|
||||
slug := testSlug(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Clean up after test.
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = store.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug))
|
||||
})
|
||||
|
||||
t.Run("UpsertBook_Create", func(t *testing.T) {
|
||||
err := store.UpsertBook(ctx, slug,
|
||||
"Integration Test Novel", "Test Author",
|
||||
"https://example.com/cover.jpg", "Ongoing",
|
||||
"A test summary.", "https://example.com/book/test",
|
||||
[]string{"Action", "Fantasy"}, 42, 7,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertBook (create): %v", err)
|
||||
}
|
||||
t.Logf("created book %q", slug)
|
||||
})
|
||||
|
||||
t.Run("GetBook", func(t *testing.T) {
|
||||
rec, found, err := store.GetBook(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBook: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("GetBook: book not found after UpsertBook")
|
||||
}
|
||||
t.Logf("GetBook record: %v", rec)
|
||||
if rec["title"] != "Integration Test Novel" {
|
||||
t.Errorf("title = %v, want %q", rec["title"], "Integration Test Novel")
|
||||
}
|
||||
if rec["author"] != "Test Author" {
|
||||
t.Errorf("author = %v, want %q", rec["author"], "Test Author")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListBooks", func(t *testing.T) {
|
||||
books, err := store.ListBooks(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBooks: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, b := range books {
|
||||
if s, _ := b["slug"].(string); s == slug {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ListBooks did not return book with slug %q (total=%d)", slug, len(books))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpsertBook_Update", func(t *testing.T) {
|
||||
err := store.UpsertBook(ctx, slug,
|
||||
"Integration Test Novel", "Test Author Updated",
|
||||
"", "Completed", "", "https://example.com/book/test",
|
||||
nil, 100, 3,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertBook (update): %v", err)
|
||||
}
|
||||
rec, found, err := store.GetBook(ctx, slug)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetBook after update: found=%v err=%v", found, err)
|
||||
}
|
||||
if rec["author"] != "Test Author Updated" {
|
||||
t.Errorf("author after update = %v, want %q", rec["author"], "Test Author Updated")
|
||||
}
|
||||
if rec["status"] != "Completed" {
|
||||
t.Errorf("status after update = %v, want %q", rec["status"], "Completed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BookMetaUpdated", func(t *testing.T) {
|
||||
ts, err := store.BookMetaUpdated(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("BookMetaUpdated: %v", err)
|
||||
}
|
||||
if ts.IsZero() {
|
||||
t.Error("BookMetaUpdated returned zero time")
|
||||
}
|
||||
t.Logf("meta_updated: %s", ts)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPocketBaseStore_ChapterIdx tests UpsertChapterIdx → ListChapterIdx →
|
||||
// CountChapterIdx.
|
||||
func TestPocketBaseStore_ChapterIdx(t *testing.T) {
|
||||
store := newTestPocketBaseStore(t)
|
||||
slug := testSlug(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = store.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug))
|
||||
})
|
||||
|
||||
chapters := []struct {
|
||||
n int
|
||||
title string
|
||||
date string
|
||||
}{
|
||||
{1, "Chapter 1: The Beginning", "2 days ago"},
|
||||
{2, "Chapter 2: Rising Action", "1 day ago"},
|
||||
{3, "Chapter 3: Climax", "3 hours ago"},
|
||||
}
|
||||
|
||||
for _, ch := range chapters {
|
||||
if err := store.UpsertChapterIdx(ctx, slug, ch.n, ch.title, ch.date); err != nil {
|
||||
t.Fatalf("UpsertChapterIdx(%d): %v", ch.n, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("ListChapterIdx", func(t *testing.T) {
|
||||
rows, err := store.ListChapterIdx(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapterIdx: %v", err)
|
||||
}
|
||||
if len(rows) != len(chapters) {
|
||||
t.Errorf("ListChapterIdx returned %d rows, want %d", len(rows), len(chapters))
|
||||
}
|
||||
for i, row := range rows {
|
||||
t.Logf("row[%d]: number=%v title=%v date_label=%v", i, row["number"], row["title"], row["date_label"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CountChapterIdx", func(t *testing.T) {
|
||||
count := store.CountChapterIdx(ctx, slug)
|
||||
if count != len(chapters) {
|
||||
t.Errorf("CountChapterIdx = %d, want %d", count, len(chapters))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpsertChapterIdx_Update", func(t *testing.T) {
|
||||
// Re-upsert chapter 2 with an updated title.
|
||||
if err := store.UpsertChapterIdx(ctx, slug, 2, "Chapter 2: Revised Title", "1 day ago"); err != nil {
|
||||
t.Fatalf("UpsertChapterIdx (update): %v", err)
|
||||
}
|
||||
rows, err := store.ListChapterIdx(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapterIdx after update: %v", err)
|
||||
}
|
||||
if store.CountChapterIdx(ctx, slug) != len(chapters) {
|
||||
t.Errorf("count changed after update: got %d, want %d", len(rows), len(chapters))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPocketBaseStore_Ranking tests SetRanking → GetRanking → RankingModTime.
|
||||
func TestPocketBaseStore_Ranking(t *testing.T) {
|
||||
store := newTestPocketBaseStore(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
slug1 := testSlug(t) + "-rank1"
|
||||
slug2 := testSlug(t) + "-rank2"
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
for _, sl := range []string{slug1, slug2} {
|
||||
_ = store.pb.deleteWhere(cleanCtx, "ranking", fmt.Sprintf(`slug="%s"`, sl))
|
||||
}
|
||||
})
|
||||
|
||||
items := []RankingItem{
|
||||
{Rank: 1, Slug: slug1, Title: "Test Book One", SourceURL: "https://example.com/1"},
|
||||
{Rank: 2, Slug: slug2, Title: "Test Book Two", SourceURL: "https://example.com/2"},
|
||||
}
|
||||
|
||||
t.Run("WriteRankingItem", func(t *testing.T) {
|
||||
for _, item := range items {
|
||||
if err := store.UpsertRankingItem(ctx, item); err != nil {
|
||||
t.Fatalf("UpsertRankingItem(%q): %v", item.Slug, err)
|
||||
}
|
||||
}
|
||||
t.Log("UpsertRankingItem succeeded")
|
||||
})
|
||||
|
||||
t.Run("ReadRankingItems", func(t *testing.T) {
|
||||
got, err := store.ListRankingItems(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRankingItems: %v", err)
|
||||
}
|
||||
found := 0
|
||||
for _, g := range got {
|
||||
if g.Slug == slug1 || g.Slug == slug2 {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Errorf("ListRankingItems: found %d of 2 test items in %d total", found, len(got))
|
||||
}
|
||||
t.Logf("ListRankingItems returned %d total items, %d test items", len(got), found)
|
||||
})
|
||||
|
||||
t.Run("RankingFreshEnough", func(t *testing.T) {
|
||||
updated, err := store.RankingLastUpdated(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RankingLastUpdated: %v", err)
|
||||
}
|
||||
if updated.IsZero() {
|
||||
t.Error("RankingLastUpdated returned zero time immediately after write")
|
||||
}
|
||||
fresh := time.Since(updated) < 24*time.Hour
|
||||
if !fresh {
|
||||
t.Errorf("RankingLastUpdated = %s; want within 24h", updated)
|
||||
}
|
||||
t.Logf("RankingLastUpdated = %s (fresh=%v)", updated, fresh)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPocketBaseStore_Progress tests SetProgress → GetProgress → AllProgress →
|
||||
// DeleteProgress.
|
||||
func TestPocketBaseStore_Progress(t *testing.T) {
|
||||
store := newTestPocketBaseStore(t)
|
||||
slug := testSlug(t)
|
||||
const sessionID = "integration-test-session-xyz"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = store.pb.deleteWhere(cleanCtx, "progress",
|
||||
fmt.Sprintf(`session_id="%s"`, sessionID))
|
||||
})
|
||||
|
||||
t.Run("SetProgress", func(t *testing.T) {
|
||||
if err := store.SetProgress(ctx, sessionID, slug, 5); err != nil {
|
||||
t.Fatalf("SetProgress: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetProgress", func(t *testing.T) {
|
||||
ch, updated, found, err := store.GetProgress(ctx, sessionID, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProgress: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("GetProgress: not found after SetProgress")
|
||||
}
|
||||
if ch != 5 {
|
||||
t.Errorf("chapter = %d, want 5", ch)
|
||||
}
|
||||
if updated.IsZero() {
|
||||
t.Error("updated time is zero")
|
||||
}
|
||||
t.Logf("chapter=%d updated=%s", ch, updated)
|
||||
})
|
||||
|
||||
t.Run("AllProgress", func(t *testing.T) {
|
||||
rows, err := store.AllProgress(ctx, sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("AllProgress: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range rows {
|
||||
if s, _ := r["slug"].(string); s == slug {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("AllProgress did not include slug %q (total=%d)", slug, len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetProgress_Update", func(t *testing.T) {
|
||||
if err := store.SetProgress(ctx, sessionID, slug, 12); err != nil {
|
||||
t.Fatalf("SetProgress (update): %v", err)
|
||||
}
|
||||
ch, _, found, err := store.GetProgress(ctx, sessionID, slug)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("GetProgress after update: found=%v err=%v", found, err)
|
||||
}
|
||||
if ch != 12 {
|
||||
t.Errorf("chapter after update = %d, want 12", ch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteProgress", func(t *testing.T) {
|
||||
if err := store.DeleteProgress(ctx, sessionID, slug); err != nil {
|
||||
t.Fatalf("DeleteProgress: %v", err)
|
||||
}
|
||||
_, _, found, err := store.GetProgress(ctx, sessionID, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProgress after delete: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("GetProgress returned found=true after DeleteProgress")
|
||||
}
|
||||
t.Log("DeleteProgress confirmed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPocketBaseStore_AudioCache tests SetAudioCache → GetAudioCache.
|
||||
func TestPocketBaseStore_AudioCache(t *testing.T) {
|
||||
store := newTestPocketBaseStore(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cacheKey := fmt.Sprintf("integration-audio-cache-test-%d", time.Now().UnixMilli())
|
||||
const filename = "speech_abc123.mp3"
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = store.pb.deleteWhere(cleanCtx, "audio_cache",
|
||||
fmt.Sprintf(`cache_key="%s"`, cacheKey))
|
||||
})
|
||||
|
||||
t.Run("SetAudioCache", func(t *testing.T) {
|
||||
if err := store.SetAudioCache(ctx, cacheKey, filename); err != nil {
|
||||
t.Fatalf("SetAudioCache: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetAudioCache", func(t *testing.T) {
|
||||
got, found, err := store.GetAudioCache(ctx, cacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAudioCache: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("GetAudioCache: not found after SetAudioCache")
|
||||
}
|
||||
if got != filename {
|
||||
t.Errorf("filename = %q, want %q", got, filename)
|
||||
}
|
||||
t.Logf("filename: %s", got)
|
||||
})
|
||||
|
||||
t.Run("GetAudioCache_Miss", func(t *testing.T) {
|
||||
got, found, err := store.GetAudioCache(ctx, "does-not-exist-ever")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAudioCache (miss): %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Errorf("GetAudioCache returned found=true for missing key, filename=%q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
338
scraper/internal/storage/minio.go
Normal file
338
scraper/internal/storage/minio.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// MinioConfig holds connection parameters for MinIO.
|
||||
type MinioConfig struct {
|
||||
Endpoint string // e.g. "minio:9000" — internal address used for all operations
|
||||
PublicEndpoint string // e.g. "minio.kalekber.cc" — used to sign presigned URLs so browsers can reach them; leave empty to use Endpoint
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
UseSSL bool
|
||||
PublicUseSSL bool // TLS for the public endpoint (usually true in prod)
|
||||
BucketChapters string // e.g. "libnovel-chapters"
|
||||
BucketAudio string // e.g. "libnovel-audio"
|
||||
BucketBrowse string // e.g. "libnovel-browse"
|
||||
}
|
||||
|
||||
// MinioClient wraps a minio.Client and exposes object operations for
|
||||
// chapters and audio files.
|
||||
type MinioClient struct {
|
||||
c *minio.Client // internal client — used for all read/write operations
|
||||
pub *minio.Client // public client — used only for generating presigned URLs
|
||||
cfg MinioConfig
|
||||
}
|
||||
|
||||
// NewMinioClient creates a connected MinIO client and ensures the required
|
||||
// buckets exist.
|
||||
func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error) {
|
||||
c, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio: new client: %w", err)
|
||||
}
|
||||
|
||||
// Public client: signs presigned URLs with the public hostname so browsers
|
||||
// can fetch them directly. Falls back to the internal client if no public
|
||||
// endpoint is configured.
|
||||
pub := c
|
||||
if cfg.PublicEndpoint != "" && cfg.PublicEndpoint != cfg.Endpoint {
|
||||
pub, err = minio.New(cfg.PublicEndpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.PublicUseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio: new public client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
|
||||
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse} {
|
||||
if bucket == "" {
|
||||
continue
|
||||
}
|
||||
if err := mc.ensureBucket(ctx, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return mc, nil
|
||||
}
|
||||
|
||||
// ensureBucket creates a bucket if it does not exist.
|
||||
func (m *MinioClient) ensureBucket(ctx context.Context, bucket string) error {
|
||||
exists, err := m.c.BucketExists(ctx, bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: bucket exists %q: %w", bucket, err)
|
||||
}
|
||||
if !exists {
|
||||
if err := m.c.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("minio: make bucket %q: %w", bucket, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Chapter objects ──────────────────────────────────────────────────────────
|
||||
|
||||
// chapterKey returns the MinIO object key for a chapter.
|
||||
// Layout: {slug}/vol-{vol}/{lo}-{hi}/chapter-{n}.md
|
||||
func chapterKey(slug string, vol, n int) string {
|
||||
const chaptersPerFolder = 50
|
||||
lo := ((n-1)/chaptersPerFolder)*chaptersPerFolder + 1
|
||||
hi := lo + chaptersPerFolder - 1
|
||||
return fmt.Sprintf("%s/vol-%d/%d-%d/chapter-%d.md", slug, vol, lo, hi, n)
|
||||
}
|
||||
|
||||
// PutChapter stores chapter markdown in MinIO.
|
||||
func (m *MinioClient) PutChapter(ctx context.Context, slug string, vol, n int, content string) error {
|
||||
key := chapterKey(slug, vol, n)
|
||||
data := []byte(content)
|
||||
_, err := m.c.PutObject(ctx, m.cfg.BucketChapters, key,
|
||||
bytes.NewReader(data), int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: "text/markdown; charset=utf-8"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: put chapter %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChapter retrieves chapter markdown from MinIO.
|
||||
func (m *MinioClient) GetChapter(ctx context.Context, slug string, vol, n int) (string, error) {
|
||||
key := chapterKey(slug, vol, n)
|
||||
obj, err := m.c.GetObject(ctx, m.cfg.BucketChapters, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minio: get chapter %s: %w", key, err)
|
||||
}
|
||||
defer obj.Close()
|
||||
data, err := io.ReadAll(obj)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minio: read chapter %s: %w", key, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ChapterExists returns true if the object for this chapter is present.
|
||||
func (m *MinioClient) ChapterExists(ctx context.Context, slug string, vol, n int) bool {
|
||||
key := chapterKey(slug, vol, n)
|
||||
_, err := m.c.StatObject(ctx, m.cfg.BucketChapters, key, minio.StatObjectOptions{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ListChapterKeys returns all object keys under slug/ in the chapters bucket,
|
||||
// sorted lexicographically (MinIO returns them in order).
|
||||
func (m *MinioClient) ListChapterKeys(ctx context.Context, slug string) ([]string, error) {
|
||||
prefix := slug + "/"
|
||||
var keys []string
|
||||
for obj := range m.c.ListObjects(ctx, m.cfg.BucketChapters,
|
||||
minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) {
|
||||
if obj.Err != nil {
|
||||
return nil, fmt.Errorf("minio: list chapters %s: %w", slug, obj.Err)
|
||||
}
|
||||
keys = append(keys, obj.Key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// CountChapters returns the number of chapter objects for a slug.
|
||||
func (m *MinioClient) CountChapters(ctx context.Context, slug string) int {
|
||||
keys, _ := m.ListChapterKeys(ctx, slug)
|
||||
return len(keys)
|
||||
}
|
||||
|
||||
// ─── Audio objects ────────────────────────────────────────────────────────────
|
||||
|
||||
// AudioObjectKey returns the MinIO key for a cached audio file.
|
||||
// Key: {slug}/ch{n}-{voice}.mp3
|
||||
func AudioObjectKey(slug string, n int, voice string) string {
|
||||
safe := sanitiseVoice(voice)
|
||||
return fmt.Sprintf("%s/ch%d-%s.mp3", slug, n, safe)
|
||||
}
|
||||
|
||||
// PutAudio stores an audio file in the audio bucket.
|
||||
func (m *MinioClient) PutAudio(ctx context.Context, key string, data []byte) error {
|
||||
_, err := m.c.PutObject(ctx, m.cfg.BucketAudio, key,
|
||||
bytes.NewReader(data), int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: "audio/mpeg"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: put audio %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAudio retrieves audio bytes from the audio bucket.
|
||||
func (m *MinioClient) GetAudio(ctx context.Context, key string) ([]byte, error) {
|
||||
obj, err := m.c.GetObject(ctx, m.cfg.BucketAudio, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio: get audio %s: %w", key, err)
|
||||
}
|
||||
defer obj.Close()
|
||||
return io.ReadAll(obj)
|
||||
}
|
||||
|
||||
// AudioExists returns true if the audio object is present in the bucket.
|
||||
func (m *MinioClient) AudioExists(ctx context.Context, key string) bool {
|
||||
_, err := m.c.StatObject(ctx, m.cfg.BucketAudio, key, minio.StatObjectOptions{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ─── Presigned URLs ───────────────────────────────────────────────────────────
|
||||
|
||||
// PresignChapter returns a presigned GET URL for a chapter object signed with
|
||||
// the internal endpoint — intended for server-side fetches only.
|
||||
func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) {
|
||||
key := chapterKey(slug, vol, n)
|
||||
u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketChapters, key, expires, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minio: presign chapter %s: %w", key, err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// PresignAudio returns a presigned GET URL for an audio object signed with
|
||||
// the public endpoint so the browser can fetch it directly.
|
||||
func (m *MinioClient) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
|
||||
u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAudio, key, expires, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minio: presign audio %s: %w", key, err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// ─── Browse page snapshots ────────────────────────────────────────────────────
|
||||
//
|
||||
// New bucket layout (libnovel-browse):
|
||||
//
|
||||
// {domain}/html/page-{n}.html — SingleFile HTML snapshot
|
||||
// {domain}/assets/book-covers/{slug}.jpg — downloaded cover image
|
||||
//
|
||||
// The domain segment is derived from the source URL hostname
|
||||
// (e.g. "novelfire.net"). This makes the bucket self-describing and
|
||||
// extensible to multiple sources.
|
||||
|
||||
// BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot.
|
||||
// Layout: {domain}/html/page-{n}.html
|
||||
// This uses the default (popular/all/all) filter combination.
|
||||
func BrowseHTMLKey(domain string, page int) string {
|
||||
return fmt.Sprintf("%s/html/page-%d.html", domain, page)
|
||||
}
|
||||
|
||||
// BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot
|
||||
// that includes filter parameters (sort, genre, status) in the key so that
|
||||
// different filter combinations are cached independently.
|
||||
// Layout: {domain}/html/{sort}-{genre}-{status}/page-{n}.html
|
||||
// Falls back to BrowseHTMLKey when all filters are at their default values
|
||||
// (sort=popular, genre=all, status=all) for cache compatibility.
|
||||
func BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string {
|
||||
if (sort == "" || sort == "popular") && (genre == "" || genre == "all") && (status == "" || status == "all") {
|
||||
return BrowseHTMLKey(domain, page)
|
||||
}
|
||||
if sort == "" {
|
||||
sort = "popular"
|
||||
}
|
||||
if genre == "" {
|
||||
genre = "all"
|
||||
}
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
return fmt.Sprintf("%s/html/%s-%s-%s/page-%d.html", domain, sort, genre, status, page)
|
||||
}
|
||||
|
||||
// BrowseCoverKey returns the MinIO object key for a cached book cover image.
|
||||
// Layout: {domain}/assets/book-covers/{slug}.jpg
|
||||
func BrowseCoverKey(domain, slug string) string {
|
||||
return fmt.Sprintf("%s/assets/book-covers/%s.jpg", domain, slug)
|
||||
}
|
||||
|
||||
// PutBrowsePage stores a SingleFile HTML snapshot in the browse bucket.
|
||||
func (m *MinioClient) PutBrowsePage(ctx context.Context, key, html string) error {
|
||||
data := []byte(html)
|
||||
_, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key,
|
||||
bytes.NewReader(data), int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: "text/html; charset=utf-8"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: put browse page %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBrowsePage retrieves a SingleFile HTML snapshot from the browse bucket.
|
||||
// Returns ("", false, nil) when the object does not exist.
|
||||
func (m *MinioClient) GetBrowsePage(ctx context.Context, key string) (string, bool, error) {
|
||||
obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("minio: get browse page %s: %w", key, err)
|
||||
}
|
||||
defer obj.Close()
|
||||
// Check whether the object actually exists by inspecting the Stat.
|
||||
if _, statErr := obj.Stat(); statErr != nil {
|
||||
return "", false, nil // not found
|
||||
}
|
||||
data, err := io.ReadAll(obj)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("minio: read browse page %s: %w", key, err)
|
||||
}
|
||||
return string(data), true, nil
|
||||
}
|
||||
|
||||
// BrowsePageExists returns true if a snapshot object is present in the browse bucket.
|
||||
func (m *MinioClient) BrowsePageExists(ctx context.Context, key string) bool {
|
||||
_, err := m.c.StatObject(ctx, m.cfg.BucketBrowse, key, minio.StatObjectOptions{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// PutBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket.
|
||||
// contentType should be the MIME type, e.g. "image/jpeg".
|
||||
func (m *MinioClient) PutBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error {
|
||||
_, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key,
|
||||
bytes.NewReader(data), int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: contentType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: put browse asset %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBrowseAsset retrieves a binary asset from the browse bucket.
|
||||
// Returns (nil, false, nil) when the object does not exist.
|
||||
func (m *MinioClient) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) {
|
||||
obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("minio: get browse asset %s: %w", key, err)
|
||||
}
|
||||
defer obj.Close()
|
||||
info, statErr := obj.Stat()
|
||||
if statErr != nil {
|
||||
return nil, "", false, nil // not found
|
||||
}
|
||||
data, err := io.ReadAll(obj)
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("minio: read browse asset %s: %w", key, err)
|
||||
}
|
||||
return data, info.ContentType, true, nil
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// sanitiseVoice converts a voice name to a filename-safe string.
|
||||
func sanitiseVoice(voice string) string {
|
||||
return 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)
|
||||
}
|
||||
800
scraper/internal/storage/pocketbase.go
Normal file
800
scraper/internal/storage/pocketbase.go
Normal file
@@ -0,0 +1,800 @@
|
||||
// Package storage — PocketBase REST client.
|
||||
//
|
||||
// Collections expected in PocketBase:
|
||||
//
|
||||
// books — slug(text,unique), title, author, cover, status, genres(json),
|
||||
// summary, total_chapters(number), source_url, ranking(number), updated(date)
|
||||
// chapters_idx — slug(text), number(number), title, date_label, updated(date)
|
||||
// ranking — rank(number), slug(text,unique), title, author, cover, status,
|
||||
// genres(json), source_url, updated(date)
|
||||
// progress — session_id(text), slug(text), chapter(number), updated(date)
|
||||
// audio_cache — cache_key(text,unique), filename(text), updated(date)
|
||||
// app_users — username(text,unique), password_hash(text), role(text), created(date)
|
||||
// scraping_tasks — id(auto), kind(text), target_url(text), status(text),
|
||||
// books_found(number), chapters_scraped(number),
|
||||
// chapters_skipped(number), errors(number),
|
||||
// started(date), finished(date), error_message(text)
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
// PocketBaseConfig holds PocketBase connection settings.
|
||||
type PocketBaseConfig struct {
|
||||
BaseURL string // e.g. "http://pocketbase:8090"
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
}
|
||||
|
||||
// pbClient is a minimal PocketBase admin REST client.
|
||||
type pbClient struct {
|
||||
cfg PocketBaseConfig
|
||||
httpClient *http.Client
|
||||
log *slog.Logger
|
||||
|
||||
tokenMu sync.RWMutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// newPBClient creates a new PocketBase client. It does not authenticate yet;
|
||||
// authentication happens lazily on the first API call.
|
||||
func newPBClient(cfg PocketBaseConfig, log *slog.Logger) *pbClient {
|
||||
return &pbClient{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *pbClient) authenticate(ctx context.Context) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"identity": p.cfg.AdminEmail,
|
||||
"password": p.cfg.AdminPassword,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
p.cfg.BaseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocketbase: auth: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pocketbase: auth status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("pocketbase: decode auth: %w", err)
|
||||
}
|
||||
p.tokenMu.Lock()
|
||||
p.token = result.Token
|
||||
p.tokenExp = time.Now().Add(12 * time.Hour)
|
||||
p.tokenMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pbClient) authToken(ctx context.Context) (string, error) {
|
||||
p.tokenMu.RLock()
|
||||
tok, exp := p.token, p.tokenExp
|
||||
p.tokenMu.RUnlock()
|
||||
if tok != "" && time.Now().Before(exp) {
|
||||
return tok, nil
|
||||
}
|
||||
if err := p.authenticate(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.tokenMu.RLock()
|
||||
defer p.tokenMu.RUnlock()
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
// ─── Generic CRUD helpers ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *pbClient) do(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
|
||||
tok, err := p.authToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return p.httpClient.Do(req)
|
||||
}
|
||||
|
||||
// listOne fetches the first matching record from a collection.
|
||||
func (p *pbClient) listOne(ctx context.Context, collection, filter string) (map[string]interface{}, error) {
|
||||
q := url.Values{}
|
||||
q.Set("filter", filter)
|
||||
q.Set("perPage", "1")
|
||||
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
|
||||
resp, err := p.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("pocketbase: listOne %s: status %d: %s", collection, resp.StatusCode, b)
|
||||
}
|
||||
var result struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("pocketbase: listOne %s: decode: %w", collection, err)
|
||||
}
|
||||
if len(result.Items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return result.Items[0], nil
|
||||
}
|
||||
|
||||
// listAll returns all records from a collection matching filter by paginating
|
||||
// through all pages (PocketBase default page size is capped at 500).
|
||||
func (p *pbClient) listAll(ctx context.Context, collection, filter, sort string) ([]map[string]interface{}, error) {
|
||||
const perPage = 500
|
||||
var all []map[string]interface{}
|
||||
|
||||
for page := 1; ; page++ {
|
||||
q := url.Values{}
|
||||
if filter != "" {
|
||||
q.Set("filter", filter)
|
||||
}
|
||||
if sort != "" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
q.Set("perPage", fmt.Sprintf("%d", perPage))
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
|
||||
resp, err := p.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("pocketbase: listAll %s: status %d: %s", collection, resp.StatusCode, b)
|
||||
}
|
||||
var result struct {
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("pocketbase: listAll %s: decode: %w", collection, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
all = append(all, result.Items...)
|
||||
if page >= result.TotalPages || len(result.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// upsert creates a record; if one matching filter already exists it updates it.
|
||||
func (p *pbClient) upsert(ctx context.Context, collection, filter string, data map[string]interface{}) error {
|
||||
existing, err := p.listOne(ctx, collection, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
id := existing["id"].(string)
|
||||
resp, err := p.do(ctx, http.MethodPatch,
|
||||
fmt.Sprintf("/api/collections/%s/records/%s", collection, id), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pocketbase: upsert (patch) %s id=%s: status %d: %s", collection, id, resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := p.do(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/api/collections/%s/records", collection), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pocketbase: upsert (create) %s: status %d: %s", collection, resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteWhere deletes all records matching filter in collection.
|
||||
func (p *pbClient) deleteWhere(ctx context.Context, collection, filter string) error {
|
||||
items, err := p.listAll(ctx, collection, filter, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["id"].(string)
|
||||
resp, err := p.do(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("/api/collections/%s/records/%s", collection, id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("pocketbase: deleteWhere %s id=%s: status %d: %s", collection, id, resp.StatusCode, b)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── PocketBaseStore ──────────────────────────────────────────────────────────
|
||||
|
||||
// PocketBaseStore implements the structured-data portion of the Store interface
|
||||
// backed by PocketBase REST API.
|
||||
type PocketBaseStore struct {
|
||||
pb *pbClient
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewPocketBaseStore returns a connected PocketBaseStore.
|
||||
func NewPocketBaseStore(cfg PocketBaseConfig, log *slog.Logger) *PocketBaseStore {
|
||||
return &PocketBaseStore{pb: newPBClient(cfg, log), log: log}
|
||||
}
|
||||
|
||||
// Ping verifies connectivity by authenticating.
|
||||
func (s *PocketBaseStore) Ping(ctx context.Context) error {
|
||||
_, err := s.pb.authToken(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// ─── Collections schema bootstrap ────────────────────────────────────────────
|
||||
// CollectionDef maps a collection name to its fields for auto-creation.
|
||||
|
||||
// EnsureCollections creates missing collections via the PocketBase API.
|
||||
// Safe to call on every startup — existing collections are skipped.
|
||||
func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
||||
// We just attempt to create each collection; 400/422 errors for "already
|
||||
// exists" are silently ignored.
|
||||
// PocketBase v0.22+ uses "fields"; older versions used "schema".
|
||||
// We use "fields" which is the current API.
|
||||
collections := []map[string]interface{}{
|
||||
{
|
||||
"name": "books",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"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": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "chapters_idx",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "number", "type": "number", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "date_label", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ranking",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"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"},
|
||||
{"name": "updated", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "progress",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number"},
|
||||
{"name": "updated", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "audio_cache",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "cache_key", "type": "text", "required": true},
|
||||
{"name": "filename", "type": "text"},
|
||||
{"name": "updated", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "app_users",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "username", "type": "text", "required": true},
|
||||
{"name": "password_hash", "type": "text", "required": true},
|
||||
{"name": "role", "type": "text"},
|
||||
{"name": "created", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "user_library",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "saved_at", "type": "date"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "scraping_tasks",
|
||||
"type": "base",
|
||||
"fields": []map[string]interface{}{
|
||||
{"name": "kind", "type": "text", "required": true}, // "catalogue" | "book"
|
||||
{"name": "target_url", "type": "text"}, // set for single-book scrapes
|
||||
{"name": "status", "type": "text", "required": true}, // "running" | "done" | "failed" | "cancelled"
|
||||
{"name": "books_found", "type": "number"},
|
||||
{"name": "chapters_scraped", "type": "number"},
|
||||
{"name": "chapters_skipped", "type": "number"},
|
||||
{"name": "errors", "type": "number"},
|
||||
{"name": "started", "type": "date"},
|
||||
{"name": "finished", "type": "date"},
|
||||
{"name": "error_message", "type": "text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, col := range collections {
|
||||
name, _ := col["name"].(string)
|
||||
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections", col)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocketbase: ensure collection %q: %w", name, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated:
|
||||
s.log.Info("pocketbase: collection created", "collection", name)
|
||||
case http.StatusBadRequest, http.StatusUnprocessableEntity:
|
||||
// Already exists or schema mismatch — expected on subsequent startups.
|
||||
s.log.Debug("pocketbase: collection already exists (skipped)", "collection", name)
|
||||
default:
|
||||
s.log.Warn("pocketbase: unexpected status ensuring collection",
|
||||
"collection", name, "status", resp.StatusCode, "body", string(b))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Schema migrations ────────────────────────────────────────────────────────
|
||||
|
||||
// migration describes a single field to guarantee exists in a collection.
|
||||
type migration struct {
|
||||
collection string
|
||||
fieldName string
|
||||
fieldType string
|
||||
}
|
||||
|
||||
// migrations is the ordered list of schema changes applied on every startup.
|
||||
var migrations = []migration{
|
||||
// user_id was added to progress after initial deploy.
|
||||
{"progress", "user_id", "text"},
|
||||
}
|
||||
|
||||
// EnsureMigrations idempotently adds any fields that are missing from existing
|
||||
// collections. It fetches the current schema, checks for each field by name,
|
||||
// and PATCHes the collection only when something is absent.
|
||||
// Safe to call on every startup — no-ops when schema is already up to date.
|
||||
func (s *PocketBaseStore) EnsureMigrations(ctx context.Context) error {
|
||||
for _, m := range migrations {
|
||||
if err := s.ensureField(ctx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) ensureField(ctx context.Context, m migration) error {
|
||||
// Fetch current collection schema.
|
||||
resp, err := s.pb.do(ctx, http.MethodGet, "/api/collections/"+m.collection, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema: %w", m.collection, m.fieldName, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema status %d: %s", m.collection, m.fieldName, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var schema struct {
|
||||
ID string `json:"id"`
|
||||
Fields []map[string]interface{} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &schema); err != nil {
|
||||
return fmt.Errorf("pocketbase: ensureField %s.%s: decode schema: %w", m.collection, m.fieldName, err)
|
||||
}
|
||||
|
||||
// Check if field already exists.
|
||||
for _, f := range schema.Fields {
|
||||
if name, _ := f["name"].(string); name == m.fieldName {
|
||||
s.log.Debug("pocketbase: field already exists, skipping migration",
|
||||
"collection", m.collection, "field", m.fieldName)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Append the new field and PATCH the collection.
|
||||
newFields := append(schema.Fields, map[string]interface{}{
|
||||
"name": m.fieldName,
|
||||
"type": m.fieldType,
|
||||
})
|
||||
patch := map[string]interface{}{"fields": newFields}
|
||||
patchResp, err := s.pb.do(ctx, http.MethodPatch, "/api/collections/"+schema.ID, patch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pocketbase: ensureField %s.%s: patch: %w", m.collection, m.fieldName, err)
|
||||
}
|
||||
defer patchResp.Body.Close()
|
||||
patchBody, _ := io.ReadAll(patchResp.Body)
|
||||
if patchResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("pocketbase: ensureField %s.%s: patch status %d: %s", m.collection, m.fieldName, patchResp.StatusCode, patchBody)
|
||||
}
|
||||
s.log.Info("pocketbase: schema migration applied", "collection", m.collection, "field", m.fieldName, "type", m.fieldType)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Book metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PocketBaseStore) UpsertBook(ctx context.Context, slug, title, author, cover, status, summary, sourceURL string, genres []string, totalChapters, ranking int) error {
|
||||
genresJSON, _ := json.Marshal(genres)
|
||||
return s.pb.upsert(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)), map[string]interface{}{
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"author": author,
|
||||
"cover": cover,
|
||||
"status": status,
|
||||
"genres": string(genresJSON),
|
||||
"summary": summary,
|
||||
"total_chapters": totalChapters,
|
||||
"source_url": sourceURL,
|
||||
"ranking": ranking,
|
||||
"meta_updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) GetBook(ctx context.Context, slug string) (map[string]interface{}, bool, error) {
|
||||
rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if rec == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) ListBooks(ctx context.Context) ([]map[string]interface{}, error) {
|
||||
return s.pb.listAll(ctx, "books", "", "+title")
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) BookMetaUpdated(ctx context.Context, slug string) (time.Time, error) {
|
||||
rec, err := s.pb.listOne(ctx, "books", fmt.Sprintf(`slug="%s"`, pbEsc(slug)))
|
||||
if err != nil || rec == nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if ts, ok := rec["meta_updated"].(string); ok {
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PocketBaseStore) UpsertChapterIdx(ctx context.Context, slug string, number int, title, dateLabel string) error {
|
||||
return s.pb.upsert(ctx, "chapters_idx",
|
||||
fmt.Sprintf(`slug="%s"&&number=%d`, pbEsc(slug), number),
|
||||
map[string]interface{}{
|
||||
"slug": slug,
|
||||
"number": number,
|
||||
"title": title,
|
||||
"date_label": dateLabel,
|
||||
})
|
||||
}
|
||||
|
||||
// WriteChapterRefs upserts chapter index rows (number + title) for all refs
|
||||
// without writing any chapter text. Errors are logged and skipped; the
|
||||
// operation is best-effort.
|
||||
func (s *PocketBaseStore) WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error {
|
||||
var firstErr error
|
||||
for _, ref := range refs {
|
||||
if err := s.UpsertChapterIdx(ctx, slug, ref.Number, ref.Title, ""); err != nil {
|
||||
s.log.Warn("pocketbase: WriteChapterRefs: upsert failed",
|
||||
"slug", slug, "chapter", ref.Number, "err", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) ListChapterIdx(ctx context.Context, slug string) ([]map[string]interface{}, error) {
|
||||
return s.pb.listAll(ctx, "chapters_idx",
|
||||
fmt.Sprintf(`slug="%s"`, pbEsc(slug)), "+number")
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) CountChapterIdx(ctx context.Context, slug string) int {
|
||||
rows, err := s.ListChapterIdx(ctx, slug)
|
||||
if err != nil {
|
||||
s.log.Warn("pocketbase: CountChapterIdx failed", "slug", slug, "err", err)
|
||||
return 0
|
||||
}
|
||||
return len(rows)
|
||||
}
|
||||
|
||||
// ─── Ranking (per-item) ───────────────────────────────────────────────────────
|
||||
|
||||
func (s *PocketBaseStore) UpsertRankingItem(ctx context.Context, item RankingItem) error {
|
||||
genresJSON, _ := json.Marshal(item.Genres)
|
||||
return s.pb.upsert(ctx, "ranking", fmt.Sprintf(`slug="%s"`, pbEsc(item.Slug)), map[string]interface{}{
|
||||
"rank": item.Rank,
|
||||
"slug": item.Slug,
|
||||
"title": item.Title,
|
||||
"author": item.Author,
|
||||
"cover": item.Cover,
|
||||
"status": item.Status,
|
||||
"genres": string(genresJSON),
|
||||
"source_url": item.SourceURL,
|
||||
"updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) ListRankingItems(ctx context.Context) ([]RankingItem, error) {
|
||||
rows, err := s.pb.listAll(ctx, "ranking", "", "+rank")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]RankingItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
item := RankingItem{
|
||||
Rank: int(floatVal(r, "rank")),
|
||||
Slug: strVal(r, "slug"),
|
||||
Title: strVal(r, "title"),
|
||||
Author: strVal(r, "author"),
|
||||
Cover: strVal(r, "cover"),
|
||||
Status: strVal(r, "status"),
|
||||
SourceURL: strVal(r, "source_url"),
|
||||
}
|
||||
if ts, ok := r["updated"].(string); ok {
|
||||
item.Updated, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
switch v := r["genres"].(type) {
|
||||
case string:
|
||||
_ = json.Unmarshal([]byte(v), &item.Genres)
|
||||
case []interface{}:
|
||||
for _, g := range v {
|
||||
if s, ok := g.(string); ok {
|
||||
item.Genres = append(item.Genres, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// RankingLastUpdated returns the most recent Updated time across all ranking rows,
|
||||
// or the zero time if no rows exist.
|
||||
func (s *PocketBaseStore) RankingLastUpdated(ctx context.Context) (time.Time, error) {
|
||||
// listAll with sort "-updated" and perPage=1 is the cheapest approach.
|
||||
q := url.Values{}
|
||||
q.Set("sort", "-updated")
|
||||
q.Set("perPage", "1")
|
||||
path := fmt.Sprintf("/api/collections/ranking/records?%s", q.Encode())
|
||||
resp, err := s.pb.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var result struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return time.Time{}, fmt.Errorf("pocketbase: RankingLastUpdated: decode: %w", err)
|
||||
}
|
||||
if len(result.Items) == 0 {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
ts, _ := result.Items[0]["updated"].(string)
|
||||
t, _ := time.Parse(time.RFC3339, ts)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ─── Reading progress ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PocketBaseStore) SetProgress(ctx context.Context, sessionID, slug string, chapter int) error {
|
||||
return s.pb.upsert(ctx, "progress",
|
||||
fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)),
|
||||
map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"slug": slug,
|
||||
"chapter": chapter,
|
||||
"updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) GetProgress(ctx context.Context, sessionID, slug string) (int, time.Time, bool, error) {
|
||||
rec, err := s.pb.listOne(ctx, "progress",
|
||||
fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)))
|
||||
if err != nil {
|
||||
return 0, time.Time{}, false, err
|
||||
}
|
||||
if rec == nil {
|
||||
return 0, time.Time{}, false, nil
|
||||
}
|
||||
ch := int(floatVal(rec, "chapter"))
|
||||
var updated time.Time
|
||||
if ts, ok := rec["updated"].(string); ok {
|
||||
updated, _ = time.Parse(time.RFC3339, ts)
|
||||
}
|
||||
return ch, updated, true, nil
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) AllProgress(ctx context.Context, sessionID string) ([]map[string]interface{}, error) {
|
||||
return s.pb.listAll(ctx, "progress",
|
||||
fmt.Sprintf(`session_id="%s"`, pbEsc(sessionID)), "-updated")
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) DeleteProgress(ctx context.Context, sessionID, slug string) error {
|
||||
return s.pb.deleteWhere(ctx, "progress",
|
||||
fmt.Sprintf(`session_id="%s"&&slug="%s"`, pbEsc(sessionID), pbEsc(slug)))
|
||||
}
|
||||
|
||||
// ─── Audio cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *PocketBaseStore) SetAudioCache(ctx context.Context, cacheKey, filename string) error {
|
||||
return s.pb.upsert(ctx, "audio_cache",
|
||||
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)),
|
||||
map[string]interface{}{
|
||||
"cache_key": cacheKey,
|
||||
"filename": filename,
|
||||
"updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PocketBaseStore) GetAudioCache(ctx context.Context, cacheKey string) (string, bool, error) {
|
||||
rec, err := s.pb.listOne(ctx, "audio_cache",
|
||||
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if rec == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
filename, _ := rec["filename"].(string)
|
||||
return filename, filename != "", nil
|
||||
}
|
||||
|
||||
// ─── Scraping tasks ───────────────────────────────────────────────────────────
|
||||
|
||||
// CreateScrapingTask inserts a new scraping_tasks record with status="running"
|
||||
// and returns the newly created record's ID.
|
||||
func (s *PocketBaseStore) CreateScrapingTask(ctx context.Context, kind, targetURL string) (string, error) {
|
||||
data := map[string]interface{}{
|
||||
"kind": kind,
|
||||
"target_url": targetURL,
|
||||
"status": "running",
|
||||
"books_found": 0,
|
||||
"chapters_scraped": 0,
|
||||
"chapters_skipped": 0,
|
||||
"errors": 0,
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/scraping_tasks/records", data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("pocketbase: CreateScrapingTask: status %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
var rec map[string]interface{}
|
||||
if err := json.Unmarshal(b, &rec); err != nil {
|
||||
return "", fmt.Errorf("pocketbase: CreateScrapingTask: decode: %w", err)
|
||||
}
|
||||
id, _ := rec["id"].(string)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateScrapingTask patches counters on an existing scraping_tasks record.
|
||||
func (s *PocketBaseStore) UpdateScrapingTask(ctx context.Context, id string, data map[string]interface{}) error {
|
||||
resp, err := s.pb.do(ctx, http.MethodPatch,
|
||||
fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pocketbase: UpdateScrapingTask id=%s: status %d: %s", id, resp.StatusCode, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListScrapingTasks returns all scraping_tasks sorted by started descending.
|
||||
func (s *PocketBaseStore) ListScrapingTasks(ctx context.Context) ([]map[string]interface{}, error) {
|
||||
return s.pb.listAll(ctx, "scraping_tasks", "", "-started")
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// pbEsc escapes a string for use in a PocketBase filter expression.
|
||||
// Only escapes double-quotes to prevent injection.
|
||||
func pbEsc(s string) string {
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
|
||||
func floatVal(m map[string]interface{}, key string) float64 {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
203
scraper/internal/storage/scrape_integration_test.go
Normal file
203
scraper/internal/storage/scrape_integration_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests that combine live scraping (Browserless) with real storage
|
||||
// (MinIO + PocketBase) via HybridStore.
|
||||
//
|
||||
// These tests require ALL THREE services to be running. They are gated behind
|
||||
// the "integration" build tag and skipped when any service URL is missing.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// BROWSERLESS_URL=http://localhost:3030 \
|
||||
// MINIO_ENDPOINT=localhost:9000 \
|
||||
// POCKETBASE_URL=http://localhost:8090 \
|
||||
// go test -v -tags integration -timeout 600s \
|
||||
// github.com/libnovel/scraper/internal/storage
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/browser"
|
||||
"github.com/libnovel/scraper/internal/novelfire"
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
const (
|
||||
scrapeTestBookURL = "https://novelfire.net/book/a-dragon-against-the-whole-world"
|
||||
scrapeTestBookSlug = "a-dragon-against-the-whole-world"
|
||||
)
|
||||
|
||||
// newScrapeAndStoreFixture builds a novelfire Scraper and a HybridStore,
|
||||
// skipping the test if any required env var is absent.
|
||||
func newScrapeAndStoreFixture(t *testing.T) (*novelfire.Scraper, *HybridStore) {
|
||||
t.Helper()
|
||||
|
||||
browserlessURL := os.Getenv("BROWSERLESS_URL")
|
||||
if browserlessURL == "" {
|
||||
t.Skip("BROWSERLESS_URL not set — skipping scrape+store integration test")
|
||||
}
|
||||
if os.Getenv("MINIO_ENDPOINT") == "" {
|
||||
t.Skip("MINIO_ENDPOINT not set — skipping scrape+store integration test")
|
||||
}
|
||||
if os.Getenv("POCKETBASE_URL") == "" {
|
||||
t.Skip("POCKETBASE_URL not set — skipping scrape+store integration test")
|
||||
}
|
||||
|
||||
client := browser.NewContentClient(browser.Config{
|
||||
BaseURL: browserlessURL,
|
||||
Token: os.Getenv("BROWSERLESS_TOKEN"),
|
||||
Timeout: 120 * time.Second,
|
||||
MaxConcurrent: 1,
|
||||
})
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
sc := novelfire.New(client, log, client, nil, nil)
|
||||
hs := newTestHybridStore(t)
|
||||
return sc, hs
|
||||
}
|
||||
|
||||
// TestScrapeAndStore_BookMetadata scrapes the test book's metadata and stores
|
||||
// it via HybridStore.WriteMetadata, then verifies a ReadMetadata round-trip.
|
||||
func TestScrapeAndStore_BookMetadata(t *testing.T) {
|
||||
sc, hs := newScrapeAndStoreFixture(t)
|
||||
|
||||
slug := scrapeTestBookSlug + "-scrapetest"
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "books", fmt.Sprintf(`slug="%s"`, slug))
|
||||
})
|
||||
|
||||
// 1. Scrape metadata from the live site.
|
||||
scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer scrapeCancel()
|
||||
|
||||
meta, err := sc.ScrapeMetadata(scrapeCtx, scrapeTestBookURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrapeMetadata: %v", err)
|
||||
}
|
||||
t.Logf("scraped: slug=%q title=%q author=%q totalChapters=%d",
|
||||
meta.Slug, meta.Title, meta.Author, meta.TotalChapters)
|
||||
|
||||
// Override slug with our test-specific value to avoid polluting real data.
|
||||
meta.Slug = slug
|
||||
|
||||
// 2. Write to HybridStore.
|
||||
storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer storeCancel()
|
||||
|
||||
if err := hs.WriteMetadata(storeCtx, meta); err != nil {
|
||||
t.Fatalf("WriteMetadata: %v", err)
|
||||
}
|
||||
|
||||
// 3. Read back and verify.
|
||||
got, found, err := hs.ReadMetadata(storeCtx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMetadata: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("ReadMetadata: not found after WriteMetadata")
|
||||
}
|
||||
|
||||
t.Logf("read back: title=%q author=%q totalChapters=%d", got.Title, got.Author, got.TotalChapters)
|
||||
|
||||
if got.Title == "" {
|
||||
t.Error("Title is empty after round-trip")
|
||||
}
|
||||
if got.Author == "" {
|
||||
t.Error("Author is empty after round-trip")
|
||||
}
|
||||
if got.TotalChapters < 1 {
|
||||
t.Errorf("TotalChapters = %d, want >= 1", got.TotalChapters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrapeAndStore_First3Chapters scrapes chapters 1, 2, and 3 from the
|
||||
// live site and stores each via HybridStore.WriteChapter, then verifies
|
||||
// ReadChapter returns non-empty markdown with the expected header.
|
||||
func TestScrapeAndStore_First3Chapters(t *testing.T) {
|
||||
sc, hs := newScrapeAndStoreFixture(t)
|
||||
|
||||
// Use a unique test slug so we don't pollute the real book.
|
||||
slug := fmt.Sprintf("%s-chtest-%d", scrapeTestBookSlug, time.Now().UnixMilli()%100000)
|
||||
|
||||
t.Cleanup(func() {
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.pb.pb.deleteWhere(cleanCtx, "chapters_idx", fmt.Sprintf(`slug="%s"`, slug))
|
||||
})
|
||||
|
||||
// Pre-build chapter refs (known URLs for this test book).
|
||||
refs := []scraper.ChapterRef{
|
||||
{Number: 1, Title: "Chapter 1", Volume: 0, URL: scrapeTestBookURL + "/chapter-1"},
|
||||
{Number: 2, Title: "Chapter 2", Volume: 0, URL: scrapeTestBookURL + "/chapter-2"},
|
||||
{Number: 3, Title: "Chapter 3", Volume: 0, URL: scrapeTestBookURL + "/chapter-3"},
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
ref := ref // capture loop variable
|
||||
t.Run(fmt.Sprintf("chapter-%d", ref.Number), func(t *testing.T) {
|
||||
// 1. Scrape chapter text.
|
||||
scrapeCtx, scrapeCancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer scrapeCancel()
|
||||
|
||||
ch, err := sc.ScrapeChapterText(scrapeCtx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("ScrapeChapterText(%d): %v", ref.Number, err)
|
||||
}
|
||||
t.Logf("scraped chapter %d: %d bytes of markdown", ref.Number, len(ch.Text))
|
||||
|
||||
if len(ch.Text) < 100 {
|
||||
t.Errorf("scraped text too short (%d bytes)", len(ch.Text))
|
||||
}
|
||||
|
||||
// 2. Write to HybridStore.
|
||||
storeCtx, storeCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer storeCancel()
|
||||
|
||||
if err := hs.WriteChapter(storeCtx, slug, ch); err != nil {
|
||||
t.Fatalf("WriteChapter(%d): %v", ref.Number, err)
|
||||
}
|
||||
|
||||
// 3. Read back and verify.
|
||||
got, err := hs.ReadChapter(storeCtx, slug, ref.Number)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadChapter(%d): %v", ref.Number, err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("ReadChapter(%d): returned empty string", ref.Number)
|
||||
}
|
||||
if len(got) < 100 {
|
||||
t.Errorf("ReadChapter(%d): content too short (%d bytes)", ref.Number, len(got))
|
||||
}
|
||||
|
||||
// WriteChapter prepends "# <title>\n\n".
|
||||
if !strings.HasPrefix(got, "# ") {
|
||||
t.Errorf("chapter %d: stored content does not start with markdown header: %q",
|
||||
ref.Number, got[:min(len(got), 60)])
|
||||
}
|
||||
|
||||
// Verify the original scraped text body is present.
|
||||
if !strings.Contains(got, ch.Text[:min(len(ch.Text), 50)]) {
|
||||
t.Errorf("chapter %d: stored content does not contain scraped text excerpt", ref.Number)
|
||||
}
|
||||
|
||||
t.Logf("chapter %d stored and verified: %d bytes", ref.Number, len(got))
|
||||
})
|
||||
}
|
||||
|
||||
// After all chapters written, verify count.
|
||||
countCtx, countCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer countCancel()
|
||||
|
||||
count := hs.CountChapters(countCtx, slug)
|
||||
if count != len(refs) {
|
||||
t.Errorf("CountChapters = %d, want %d", count, len(refs))
|
||||
}
|
||||
}
|
||||
180
scraper/internal/storage/store.go
Normal file
180
scraper/internal/storage/store.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Package storage defines the unified Store interface and helper types used by
|
||||
// the server and orchestrator. Concrete implementations back the interface
|
||||
// with PocketBase (structured data) and MinIO (binary objects).
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
)
|
||||
|
||||
// ─── Shared types ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ChapterInfo is a lightweight chapter descriptor (mirrors writer.ChapterInfo).
|
||||
type ChapterInfo struct {
|
||||
Number int
|
||||
Title string
|
||||
Date string
|
||||
}
|
||||
|
||||
// RankingItem represents a single entry in the novel ranking list.
|
||||
// Aliased from scraper.RankingItem for convenience within this package.
|
||||
type RankingItem = scraper.RankingItem
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ScrapeTask represents a single scraping job record from the scraping_tasks
|
||||
// collection.
|
||||
type ScrapeTask struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "catalogue" | "book"
|
||||
TargetURL string `json:"target_url"` // non-empty for single-book scrapes
|
||||
Status string `json:"status"` // "running" | "done" | "failed" | "cancelled"
|
||||
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"`
|
||||
}
|
||||
|
||||
// ScrapeTaskUpdate carries the fields that can be patched on a ScrapeTask.
|
||||
// Zero-value fields are still sent; callers should only include keys they want
|
||||
// to change via the map form used inside the store implementation.
|
||||
type ScrapeTaskUpdate struct {
|
||||
Status string
|
||||
BooksFound int
|
||||
ChaptersScraped int
|
||||
ChaptersSkipped int
|
||||
Errors int
|
||||
Finished time.Time // zero = not finished yet
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// ─── Store interface ──────────────────────────────────────────────────────────
|
||||
|
||||
// Store is the single persistence abstraction consumed by the server and the
|
||||
// orchestrator. Implementations may route calls to different backends
|
||||
// (PocketBase for structured records, MinIO for binary blobs).
|
||||
type Store interface {
|
||||
// ── Book metadata ──────────────────────────────────────────────────────
|
||||
|
||||
// WriteMetadata upserts book metadata.
|
||||
WriteMetadata(ctx context.Context, meta scraper.BookMeta) error
|
||||
// ReadMetadata returns the metadata for slug. Returns (zero, false, nil)
|
||||
// when the book is not found.
|
||||
ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error)
|
||||
// ListBooks returns all books, sorted alphabetically by title.
|
||||
ListBooks(ctx context.Context) ([]scraper.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
|
||||
|
||||
// ── Chapters (binary blobs in MinIO) ───────────────────────────────────
|
||||
|
||||
// ChapterExists returns true if the markdown file for the given ref exists.
|
||||
ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool
|
||||
// WriteChapter stores the chapter markdown.
|
||||
WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error
|
||||
// WriteChapterRefs persists chapter metadata (number + title) into the
|
||||
// chapters_idx table without fetching or storing any chapter text.
|
||||
// It is used to pre-populate the chapter list when a book is first seen
|
||||
// via a live preview, before its chapter text has been scraped.
|
||||
WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error
|
||||
// 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) ([]ChapterInfo, error)
|
||||
// CountChapters returns the number of stored chapters for slug.
|
||||
CountChapters(ctx context.Context, slug string) int
|
||||
// ReindexChapters rebuilds chapters_idx from MinIO objects for slug.
|
||||
// Returns the number of chapters indexed.
|
||||
ReindexChapters(ctx context.Context, slug string) (int, error)
|
||||
|
||||
// ── Ranking ────────────────────────────────────────────────────────────
|
||||
|
||||
// WriteRankingItem upserts a single ranking entry (keyed on Slug).
|
||||
WriteRankingItem(ctx context.Context, item RankingItem) error
|
||||
// ReadRankingItems returns all ranking items sorted by rank ascending.
|
||||
ReadRankingItems(ctx context.Context) ([]RankingItem, error)
|
||||
// RankingFreshEnough returns true when ranking rows exist and the most
|
||||
// recent Updated timestamp is within maxAge of now.
|
||||
RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error)
|
||||
|
||||
// ── Audio cache ────────────────────────────────────────────────────────
|
||||
|
||||
// GetAudioCache returns the Kokoro filename for cacheKey, or ("", false).
|
||||
GetAudioCache(ctx context.Context, cacheKey string) (string, bool)
|
||||
// SetAudioCache persists a Kokoro filename for cacheKey.
|
||||
SetAudioCache(ctx context.Context, cacheKey, filename string) error
|
||||
// PutAudio stores raw audio bytes under the given MinIO object key.
|
||||
PutAudio(ctx context.Context, key string, data []byte) error
|
||||
|
||||
// ── Reading progress ───────────────────────────────────────────────────
|
||||
|
||||
// GetProgress returns the reading progress for the given session ID and slug.
|
||||
// Returns (zero, false) if no progress is recorded.
|
||||
GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool)
|
||||
// SetProgress saves or updates reading progress.
|
||||
SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error
|
||||
// AllProgress returns all progress entries for a session.
|
||||
AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error)
|
||||
// DeleteProgress removes progress for a specific slug.
|
||||
DeleteProgress(ctx context.Context, sessionID, slug string) error
|
||||
|
||||
// ── Audio object paths (MinIO) ─────────────────────────────────────────
|
||||
|
||||
// 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 the bucket.
|
||||
AudioExists(ctx context.Context, key string) bool
|
||||
|
||||
// ── Presigned URLs ─────────────────────────────────────────────────────
|
||||
|
||||
// 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)
|
||||
|
||||
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
|
||||
|
||||
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
|
||||
SaveBrowsePage(ctx context.Context, key, html string) error
|
||||
// GetBrowsePage retrieves a cached HTML snapshot. Returns ("", false, nil)
|
||||
// when no snapshot exists for the key.
|
||||
GetBrowsePage(ctx context.Context, key string) (string, bool, error)
|
||||
// BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot.
|
||||
// Layout: {domain}/html/page-{n}.html
|
||||
BrowseHTMLKey(domain string, page int) string
|
||||
// BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot
|
||||
// that incorporates sort/genre/status so different filter combos are cached separately.
|
||||
BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string
|
||||
// BrowseCoverKey returns the MinIO object key for a cached book cover image.
|
||||
// Layout: {domain}/assets/book-covers/{slug}.jpg
|
||||
BrowseCoverKey(domain, slug string) string
|
||||
// SaveBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket.
|
||||
SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error
|
||||
// GetBrowseAsset retrieves a binary asset from the browse bucket.
|
||||
// Returns (nil, "", false, nil) when the object does not exist.
|
||||
GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error)
|
||||
|
||||
// ── Scraping tasks ─────────────────────────────────────────────────────
|
||||
|
||||
// CreateScrapeTask inserts a new scraping_tasks record with status="running"
|
||||
// and returns the assigned ID.
|
||||
CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error)
|
||||
// UpdateScrapeTask patches an existing task record.
|
||||
UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error
|
||||
// ListScrapeTasks returns all tasks sorted by started descending.
|
||||
ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error)
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
// Package writer handles persistence of scraped chapters and metadata.
|
||||
//
|
||||
// Directory layout:
|
||||
//
|
||||
// static/books/
|
||||
// ├── {book-slug}/
|
||||
// │ ├── metadata.yaml
|
||||
// │ ├── vol-0/ (no volume grouping)
|
||||
// │ │ ├── 1-50/
|
||||
// │ │ │ ├── chapter-1.md
|
||||
// │ │ │ └── …
|
||||
// │ │ └── 51-100/
|
||||
// │ │ └── …
|
||||
// │ └── vol-1/
|
||||
// │ └── …
|
||||
package writer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const chaptersPerFolder = 50
|
||||
|
||||
// Writer persists scraped content under a configurable root directory.
|
||||
type Writer struct {
|
||||
root string // e.g. "./static/books"
|
||||
}
|
||||
|
||||
// New creates a Writer that stores files under root.
|
||||
func New(root string) *Writer {
|
||||
return &Writer{root: root}
|
||||
}
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// WriteMetadata serialises meta to static/books/{slug}/metadata.yaml.
|
||||
// It creates the directory if it does not exist and overwrites any existing file.
|
||||
func (w *Writer) WriteMetadata(meta scraper.BookMeta) error {
|
||||
dir := w.bookDir(meta.Slug)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "metadata.yaml")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writer: create metadata %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := yaml.NewEncoder(f)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(meta); err != nil {
|
||||
return fmt.Errorf("writer: encode metadata: %w", err)
|
||||
}
|
||||
return enc.Close()
|
||||
}
|
||||
|
||||
// ReadMetadata reads the metadata.yaml for slug if it exists.
|
||||
// Returns (zero-value, false, nil) when the file does not exist.
|
||||
func (w *Writer) ReadMetadata(slug string) (scraper.BookMeta, bool, error) {
|
||||
path := filepath.Join(w.bookDir(slug), "metadata.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return scraper.BookMeta{}, false, nil
|
||||
}
|
||||
return scraper.BookMeta{}, false, fmt.Errorf("writer: read metadata %s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta scraper.BookMeta
|
||||
if err := yaml.Unmarshal(data, &meta); err != nil {
|
||||
return scraper.BookMeta{}, true, fmt.Errorf("writer: unmarshal metadata %s: %w", path, err)
|
||||
}
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
// MetadataMtime returns the modification time (Unix seconds) of the
|
||||
// metadata.yaml file for slug, or 0 if the file cannot be stat'd.
|
||||
func (w *Writer) MetadataMtime(slug string) int64 {
|
||||
path := filepath.Join(w.bookDir(slug), "metadata.yaml")
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return fi.ModTime().Unix()
|
||||
}
|
||||
|
||||
// ─── Chapters ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ChapterExists returns true if the markdown file for ref already exists on disk.
|
||||
func (w *Writer) ChapterExists(slug string, ref scraper.ChapterRef) bool {
|
||||
_, err := os.Stat(w.chapterPath(slug, ref))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// WriteChapter writes chapter.Text to the appropriate markdown file.
|
||||
// The parent directories are created on demand.
|
||||
func (w *Writer) WriteChapter(slug string, chapter scraper.Chapter) error {
|
||||
path := w.chapterPath(slug, chapter.Ref)
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Build the markdown document.
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# ")
|
||||
sb.WriteString(chapter.Ref.Title)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(chapter.Text)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
|
||||
return fmt.Errorf("writer: write chapter %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Catalogue helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// ListBooks returns metadata for every book that has a metadata.yaml under root.
|
||||
// Books with unreadable metadata files are silently skipped.
|
||||
func (w *Writer) ListBooks() ([]scraper.BookMeta, error) {
|
||||
entries, err := os.ReadDir(w.root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("writer: list books: %w", err)
|
||||
}
|
||||
var books []scraper.BookMeta
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
meta, ok, _ := w.ReadMetadata(e.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
books = append(books, meta)
|
||||
}
|
||||
sort.Slice(books, func(i, j int) bool {
|
||||
return books[i].Title < books[j].Title
|
||||
})
|
||||
return books, nil
|
||||
}
|
||||
|
||||
// LocalSlugs returns the set of book slugs that have a metadata.yaml on disk.
|
||||
// It is cheaper than ListBooks because it only checks for file existence rather
|
||||
// than fully parsing every YAML file.
|
||||
func (w *Writer) LocalSlugs() map[string]bool {
|
||||
entries, err := os.ReadDir(w.root)
|
||||
if err != nil {
|
||||
return map[string]bool{}
|
||||
}
|
||||
slugs := make(map[string]bool, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
metaPath := filepath.Join(w.root, e.Name(), "metadata.yaml")
|
||||
if _, err := os.Stat(metaPath); err == nil {
|
||||
slugs[e.Name()] = true
|
||||
}
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
// ChapterInfo is a lightweight chapter descriptor derived from on-disk files.
|
||||
type ChapterInfo struct {
|
||||
Number int
|
||||
Title string // chapter name, cleaned of number prefix and trailing date
|
||||
Date string // relative date scraped alongside the title, e.g. "1 year ago"
|
||||
}
|
||||
|
||||
// ListChapters returns all chapters on disk for slug, sorted by number.
|
||||
func (w *Writer) ListChapters(slug string) ([]ChapterInfo, error) {
|
||||
bookDir := w.bookDir(slug)
|
||||
var chapters []ChapterInfo
|
||||
|
||||
// Walk vol-*/range-*/ directories.
|
||||
volDirs, err := filepath.Glob(filepath.Join(bookDir, "vol-*"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("writer: list chapters glob: %w", err)
|
||||
}
|
||||
for _, vd := range volDirs {
|
||||
rangeDirs, _ := filepath.Glob(filepath.Join(vd, "*-*"))
|
||||
for _, rd := range rangeDirs {
|
||||
files, _ := filepath.Glob(filepath.Join(rd, "chapter-*.md"))
|
||||
for _, f := range files {
|
||||
base := filepath.Base(f) // chapter-N.md
|
||||
numStr := strings.TrimSuffix(strings.TrimPrefix(base, "chapter-"), ".md")
|
||||
n, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
title, date := chapterTitle(f, n)
|
||||
chapters = append(chapters, ChapterInfo{Number: n, Title: title, Date: date})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(chapters, func(i, j int) bool {
|
||||
return chapters[i].Number < chapters[j].Number
|
||||
})
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
// CountChapters returns the number of chapter markdown files on disk for slug.
|
||||
// It is cheaper than ListChapters because it does not read file contents.
|
||||
func (w *Writer) CountChapters(slug string) int {
|
||||
bookDir := w.bookDir(slug)
|
||||
volDirs, err := filepath.Glob(filepath.Join(bookDir, "vol-*"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, vd := range volDirs {
|
||||
rangeDirs, _ := filepath.Glob(filepath.Join(vd, "*-*"))
|
||||
for _, rd := range rangeDirs {
|
||||
files, _ := filepath.Glob(filepath.Join(rd, "chapter-*.md"))
|
||||
count += len(files)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// chapterTitle reads the first non-empty line of a markdown file and strips
|
||||
// the leading "# " heading marker. Falls back to "Chapter N".
|
||||
func chapterTitle(path string, n int) (title, date string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Chapter %d", n), ""
|
||||
}
|
||||
for _, line := range strings.SplitN(string(data), "\n", 10) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "# ")
|
||||
return SplitChapterTitle(line)
|
||||
}
|
||||
return fmt.Sprintf("Chapter %d", n), ""
|
||||
}
|
||||
|
||||
// SplitChapterTitle separates the human-readable chapter name from the
|
||||
// trailing relative-date string that novelfire.net appends to the heading.
|
||||
// Examples of raw heading text (after stripping "# "):
|
||||
//
|
||||
// "1 Chapter 1 - 1: The Academy's Weakest1 year ago"
|
||||
// "2 Chapter 2 - Enter the Storm3 months ago"
|
||||
//
|
||||
// The pattern is: optional leading number+whitespace, then the real title,
|
||||
// then a date that matches /\d+\s+(second|minute|hour|day|week|month|year)s?\s+ago$/
|
||||
func SplitChapterTitle(raw string) (title, date string) {
|
||||
// Strip a leading chapter-number index that novelfire sometimes prepends.
|
||||
// It looks like "1 " or "12 " at the very start.
|
||||
raw = strings.TrimSpace(raw)
|
||||
if idx := strings.IndexFunc(raw, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 {
|
||||
prefix := raw[:idx]
|
||||
allDigit := true
|
||||
for _, c := range prefix {
|
||||
if c < '0' || c > '9' {
|
||||
allDigit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigit {
|
||||
raw = strings.TrimSpace(raw[idx:])
|
||||
}
|
||||
}
|
||||
|
||||
// Strip "Chapter N - N: " prefix (novelfire double-number format).
|
||||
// Also handles "Chapter N: " (single number) and "Chapter N - Title" without colon.
|
||||
chNumRe := regexp.MustCompile(`(?i)^chapter\s+\d+(?:\s*-\s*\d+)?\s*:\s*`)
|
||||
raw = strings.TrimSpace(chNumRe.ReplaceAllString(raw, ""))
|
||||
|
||||
// Match a trailing relative date: "<n> <unit>[s] ago"
|
||||
dateRe := regexp.MustCompile(`\s*(\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)\s*$`)
|
||||
if m := dateRe.FindStringSubmatchIndex(raw); m != nil {
|
||||
return strings.TrimSpace(raw[:m[0]]), strings.TrimSpace(raw[m[2]:m[3]])
|
||||
}
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
// ReadChapter returns the raw markdown content for chapter number n of slug.
|
||||
func (w *Writer) ReadChapter(slug string, n int) (string, error) {
|
||||
// Reconstruct path using the same bucketing formula as chapterPath.
|
||||
ref := scraper.ChapterRef{Number: n, Volume: 0}
|
||||
path := w.chapterPath(slug, ref)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("writer: read chapter %d: %w", n, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ─── Ranking ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// RankingItem represents a single entry in the ranking.
|
||||
type RankingItem struct {
|
||||
Rank int `yaml:"rank" json:"rank"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Author string `yaml:"author,omitempty" json:"author,omitempty"`
|
||||
Cover string `yaml:"cover,omitempty" json:"cover,omitempty"`
|
||||
Status string `yaml:"status,omitempty" json:"status,omitempty"`
|
||||
Genres []string `yaml:"genres,omitempty" json:"genres,omitempty"`
|
||||
SourceURL string `yaml:"source_url,omitempty" json:"source_url,omitempty"`
|
||||
}
|
||||
|
||||
// WriteRanking saves the ranking items as JSON to static/books/ranking.json.
|
||||
// This replaces the old markdown table format with a structured format that
|
||||
// is faster to read back (no custom parsing) and safe for titles containing "|".
|
||||
func (w *Writer) WriteRanking(items []RankingItem) error {
|
||||
path := filepath.Clean(w.rankingPath())
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("writer: mkdir %s: %w", dir, err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(items, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("writer: marshal ranking: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("writer: write ranking %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadRankingItems parses ranking.json into a slice of RankingItem.
|
||||
// Returns nil slice (not an error) when the file does not exist yet.
|
||||
func (w *Writer) ReadRankingItems() ([]RankingItem, error) {
|
||||
data, err := os.ReadFile(w.rankingPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("writer: read ranking: %w", err)
|
||||
}
|
||||
var items []RankingItem
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
return nil, fmt.Errorf("writer: parse ranking json: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// RankingFileInfo returns os.FileInfo for the ranking.json file, if it exists.
|
||||
func (w *Writer) RankingFileInfo() (os.FileInfo, error) {
|
||||
return os.Stat(w.rankingPath())
|
||||
}
|
||||
|
||||
func (w *Writer) rankingPath() string {
|
||||
return filepath.Join(w.root, "ranking.json")
|
||||
}
|
||||
|
||||
// ─── Ranking page HTML cache ──────────────────────────────────────────────────
|
||||
|
||||
// rankingCacheDir returns the directory that stores per-page HTML caches.
|
||||
func (w *Writer) rankingCacheDir() string {
|
||||
return filepath.Join(w.root, "_ranking_cache")
|
||||
}
|
||||
|
||||
// rankingPageCachePath returns the path for a cached ranking page HTML file.
|
||||
func (w *Writer) rankingPageCachePath(page int) string {
|
||||
return filepath.Join(w.rankingCacheDir(), fmt.Sprintf("page-%d.html", page))
|
||||
}
|
||||
|
||||
// WriteRankingPageCache persists raw HTML for the given ranking page number.
|
||||
func (w *Writer) WriteRankingPageCache(page int, html string) error {
|
||||
dir := w.rankingCacheDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("writer: mkdir ranking cache %s: %w", dir, err)
|
||||
}
|
||||
path := w.rankingPageCachePath(page)
|
||||
if err := os.WriteFile(path, []byte(html), 0o644); err != nil {
|
||||
return fmt.Errorf("writer: write ranking page cache %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadRankingPageCache reads the cached HTML for the given ranking page.
|
||||
// Returns ("", nil) when no cache file exists yet.
|
||||
func (w *Writer) ReadRankingPageCache(page int) (string, error) {
|
||||
data, err := os.ReadFile(w.rankingPageCachePath(page))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("writer: read ranking page cache page %d: %w", page, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// RankingPageCacheInfo returns os.FileInfo for a cached ranking page file.
|
||||
// Returns (nil, nil) when the file does not exist.
|
||||
func (w *Writer) RankingPageCacheInfo(page int) (os.FileInfo, error) {
|
||||
info, err := os.Stat(w.rankingPageCachePath(page))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// bookDir returns the root directory for a book slug.
|
||||
func (w *Writer) bookDir(slug string) string {
|
||||
return filepath.Join(w.root, slug)
|
||||
}
|
||||
|
||||
// AudioDir returns the directory used to cache generated MP3 files for a book.
|
||||
func (w *Writer) AudioDir(slug string) string {
|
||||
return filepath.Join(w.bookDir(slug), "audio")
|
||||
}
|
||||
|
||||
// AudioPath returns the full path for a cached chapter audio file.
|
||||
// The filename is keyed by chapter number, voice, and speed so that different
|
||||
// settings never collide. Speed is formatted to one decimal place (e.g. "1.0").
|
||||
func (w *Writer) AudioPath(slug string, n int, voice string, speed float64) string {
|
||||
safeVoice := sanitiseVoice(voice)
|
||||
filename := fmt.Sprintf("ch%d-%s-%.1f.mp3", n, safeVoice, speed)
|
||||
return filepath.Join(w.AudioDir(slug), filename)
|
||||
}
|
||||
|
||||
// AudioPartPath returns the path for an individual audio chunk generated during
|
||||
// chunked TTS. Part files are named ch{n}-{voice}-{speed}.part{p}.mp3 and are
|
||||
// deleted after they have been merged into the final AudioPath file.
|
||||
func (w *Writer) AudioPartPath(slug string, n int, voice string, speed float64, part int) string {
|
||||
safeVoice := sanitiseVoice(voice)
|
||||
filename := fmt.Sprintf("ch%d-%s-%.1f.part%d.mp3", n, safeVoice, speed, part)
|
||||
return filepath.Join(w.AudioDir(slug), filename)
|
||||
}
|
||||
|
||||
// sanitiseVoice converts a voice name into a string that is safe to embed in a
|
||||
// filename (only a-z, A-Z, 0-9, '_', '-' are kept; everything else becomes '_').
|
||||
func sanitiseVoice(voice string) string {
|
||||
return 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)
|
||||
}
|
||||
|
||||
// chapterPath computes the full file path for a chapter.
|
||||
//
|
||||
// vol-{volume}/{folderRange}/chapter-{number}.md
|
||||
//
|
||||
// Example: vol-0/1-50/chapter-1.md, vol-0/51-100/chapter-51.md
|
||||
func (w *Writer) chapterPath(slug string, ref scraper.ChapterRef) string {
|
||||
vol := ref.Volume // 0 == no volume grouping
|
||||
volDir := fmt.Sprintf("vol-%d", vol)
|
||||
|
||||
// Folder group: chapters 1-50 → "1-50", 51-100 → "51-100", …
|
||||
lo := ((ref.Number-1)/chaptersPerFolder)*chaptersPerFolder + 1
|
||||
hi := lo + chaptersPerFolder - 1
|
||||
rangeDir := fmt.Sprintf("%d-%d", lo, hi)
|
||||
|
||||
filename := fmt.Sprintf("chapter-%d.md", ref.Number)
|
||||
|
||||
return filepath.Join(w.bookDir(slug), volDir, rangeDir, filename)
|
||||
}
|
||||
BIN
scraper/scraper
BIN
scraper/scraper
Binary file not shown.
5
scraper/tools.go
Normal file
5
scraper/tools.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build tools
|
||||
|
||||
package tools
|
||||
|
||||
import _ "honnef.co/go/tools/cmd/staticcheck"
|
||||
99
scripts/link-tooltip.user.js
Normal file
99
scripts/link-tooltip.user.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ==UserScript==
|
||||
// @name Link URL Tooltip
|
||||
// @namespace https://github.com/kalekber/libnovel-v2
|
||||
// @version 1.0.0
|
||||
// @description Show the destination URL near the cursor when hovering over any link
|
||||
// @author kalekber
|
||||
// @match *://*/*
|
||||
// @run-at document-idle
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- Inject styles ---
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#lnk-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
z-index: 2147483647;
|
||||
white-space: nowrap;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// --- Inject tooltip element ---
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.id = 'lnk-tooltip';
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// --- Helpers ---
|
||||
function getAnchor(target) {
|
||||
// Walk up the DOM to find the nearest <a href="...">
|
||||
// (handles clicks on nested elements like <a><span>text</span></a>)
|
||||
return target.closest('a[href]');
|
||||
}
|
||||
|
||||
function show(anchor, clientX, clientY) {
|
||||
tooltip.textContent = anchor.href;
|
||||
tooltip.style.display = 'block';
|
||||
position(clientX, clientY);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
tooltip.style.display = 'none';
|
||||
}
|
||||
|
||||
function position(clientX, clientY) {
|
||||
const offset = 12;
|
||||
const tw = tooltip.offsetWidth;
|
||||
const th = tooltip.offsetHeight;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let x = clientX + offset;
|
||||
let y = clientY + offset;
|
||||
|
||||
// Flip horizontally if it would overflow the right edge
|
||||
if (x + tw > vw - 4) {
|
||||
x = clientX - tw - offset;
|
||||
}
|
||||
// Flip vertically if it would overflow the bottom edge
|
||||
if (y + th > vh - 4) {
|
||||
y = clientY - th - offset;
|
||||
}
|
||||
|
||||
tooltip.style.left = Math.max(0, x) + 'px';
|
||||
tooltip.style.top = Math.max(0, y) + 'px';
|
||||
}
|
||||
|
||||
// --- Event delegation on document ---
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const anchor = getAnchor(e.target);
|
||||
if (anchor) show(anchor, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (tooltip.style.display === 'block') {
|
||||
position(e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const anchor = getAnchor(e.target);
|
||||
if (anchor) hide();
|
||||
});
|
||||
})();
|
||||
204
scripts/pb-init.sh
Executable file
204
scripts/pb-init.sh
Executable file
@@ -0,0 +1,204 @@
|
||||
#!/bin/sh
|
||||
# pb-init.sh — idempotent PocketBase collection bootstrap
|
||||
#
|
||||
# Creates all collections required by libnovel. Safe to re-run: POST returns
|
||||
# 400/422 when a collection already exists; both are treated as success.
|
||||
#
|
||||
# Required env vars (with defaults):
|
||||
# POCKETBASE_URL http://pocketbase:8090
|
||||
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
||||
# POCKETBASE_ADMIN_PASSWORD changeme123
|
||||
|
||||
set -e
|
||||
|
||||
PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}"
|
||||
PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
|
||||
log() { echo "[pb-init] $*"; }
|
||||
|
||||
# ─── 1. Wait for PocketBase to be ready ──────────────────────────────────────
|
||||
log "waiting for PocketBase at $PB_URL ..."
|
||||
until wget -qO- "$PB_URL/api/health" > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
log "PocketBase is up"
|
||||
|
||||
# ─── 2. Authenticate and obtain a superuser token ────────────────────────────
|
||||
log "authenticating as $PB_EMAIL ..."
|
||||
AUTH_RESPONSE=$(wget -qO- \
|
||||
--header="Content-Type: application/json" \
|
||||
--post-data="{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}" \
|
||||
"$PB_URL/api/collections/_superusers/auth-with-password")
|
||||
|
||||
TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then
|
||||
log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log "auth token obtained"
|
||||
|
||||
# ─── 3. Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
create_collection() {
|
||||
NAME="$1"
|
||||
BODY="$2"
|
||||
STATUS=$(wget -qSO- \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="Authorization: Bearer $TOKEN" \
|
||||
--post-data="$BODY" \
|
||||
"$PB_URL/api/collections" 2>&1 | grep "^ HTTP/" | awk '{print $2}')
|
||||
case "$STATUS" in
|
||||
200|201) log "created collection: $NAME" ;;
|
||||
400|422) log "collection already exists (skipped): $NAME" ;;
|
||||
*) log "WARNING: unexpected status $STATUS for collection: $NAME" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ensure_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
#
|
||||
# Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing,
|
||||
# sends a PATCH with the full current fields list plus the new field appended.
|
||||
# Uses only busybox sh + wget + sed/awk — no python/jq required.
|
||||
ensure_field() {
|
||||
COLL="$1"
|
||||
FIELD_NAME="$2"
|
||||
FIELD_TYPE="$3"
|
||||
|
||||
SCHEMA=$(wget -qO- \
|
||||
--header="Authorization: Bearer $TOKEN" \
|
||||
"$PB_URL/api/collections/$COLL" 2>/dev/null)
|
||||
|
||||
# Check if the field already exists (look for "name":"<FIELD_NAME>" in the fields array)
|
||||
if echo "$SCHEMA" | grep -q "\"name\":\"$FIELD_NAME\""; then
|
||||
log "field $COLL.$FIELD_NAME already exists — skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
COLLECTION_ID=$(echo "$SCHEMA" | sed 's/.*"id":"\([^"]*\)".*/\1/')
|
||||
if [ -z "$COLLECTION_ID" ] || [ "$COLLECTION_ID" = "$SCHEMA" ]; then
|
||||
log "WARNING: could not get id for collection $COLL — skipping ensure_field"
|
||||
return
|
||||
fi
|
||||
|
||||
# Extract current fields array (everything between the outermost [ ] of "fields":[...])
|
||||
# and append the new field object before the closing bracket.
|
||||
CURRENT_FIELDS=$(echo "$SCHEMA" | sed 's/.*"fields":\(\[.*\]\).*/\1/')
|
||||
# Strip the trailing ] and append the new field
|
||||
TRIMMED=$(echo "$CURRENT_FIELDS" | sed 's/]$//')
|
||||
NEW_FIELDS="${TRIMMED},{\"name\":\"${FIELD_NAME}\",\"type\":\"${FIELD_TYPE}\"}]"
|
||||
PATCH_BODY="{\"fields\":${NEW_FIELDS}}"
|
||||
|
||||
STATUS=$(wget -qSO- \
|
||||
--header="Content-Type: application/json" \
|
||||
--header="Authorization: Bearer $TOKEN" \
|
||||
--body-data="$PATCH_BODY" \
|
||||
--method=PATCH \
|
||||
"$PB_URL/api/collections/$COLLECTION_ID" 2>&1 | grep "^ HTTP/" | awk '{print $2}')
|
||||
case "$STATUS" in
|
||||
200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;;
|
||||
*) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── 4. Create collections (idempotent — skips if already exist) ─────────────
|
||||
|
||||
create_collection "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": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "chapters_idx" '{
|
||||
"name": "chapters_idx",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "number", "type": "number", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "date_label", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "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"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "progress" '{
|
||||
"name": "progress",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "audio_cache" '{
|
||||
"name": "audio_cache",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "cache_key", "type": "text", "required": true},
|
||||
{"name": "filename", "type": "text"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "app_users" '{
|
||||
"name": "app_users",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "username", "type": "text", "required": true},
|
||||
{"name": "password_hash", "type": "text", "required": true},
|
||||
{"name": "role", "type": "text"},
|
||||
{"name": "created", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "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": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
# ─── 5. Schema migrations (idempotent field additions) ───────────────────────
|
||||
# Ensures fields added after initial deploy are present in existing instances.
|
||||
|
||||
ensure_field "progress" "user_id" "text"
|
||||
ensure_field "progress" "audio_time" "number"
|
||||
ensure_field "user_settings" "user_id" "text"
|
||||
|
||||
log "all collections ready"
|
||||
109
scripts/runner-config.yaml
Normal file
109
scripts/runner-config.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
||||
|
||||
# You don't have to copy this file to your instance,
|
||||
# just run `./act_runner generate-config > config.yaml` to generate a config file.
|
||||
|
||||
log:
|
||||
# The level of logging, can be trace, debug, info, warn, error, fatal
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Where to store the registration result.
|
||||
file: .runner
|
||||
# Execute how many tasks concurrently at the same time.
|
||||
capacity: 1
|
||||
# Extra environment variables to run jobs.
|
||||
envs:
|
||||
# Extra environment variables to run jobs from a file.
|
||||
# It will be ignored if it's empty or the file doesn't exist.
|
||||
env_file: .env
|
||||
# The timeout for a job to be finished.
|
||||
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
|
||||
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
|
||||
timeout: 3h
|
||||
# The timeout for the runner to wait for running jobs to finish when shutting down.
|
||||
# Any running jobs that haven't finished after this timeout will be cancelled.
|
||||
shutdown_timeout: 0s
|
||||
# Whether skip verifying the TLS certificate of the Gitea instance.
|
||||
insecure: false
|
||||
# The timeout for fetching the job from the Gitea instance.
|
||||
fetch_timeout: 5s
|
||||
# The interval for fetching the job from the Gitea instance.
|
||||
fetch_interval: 2s
|
||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||
# and github_mirror is not empty. In this case,
|
||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||
github_mirror: ''
|
||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||
# If it's empty when registering, it will ask for inputting labels.
|
||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||
|
||||
cache:
|
||||
# Enable cache server to use actions/cache.
|
||||
enabled: true
|
||||
# The directory to store the cache data.
|
||||
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
|
||||
dir: ""
|
||||
# The host of the cache server.
|
||||
# It's not for the address to listen, but the address to connect from job containers.
|
||||
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
|
||||
host: ""
|
||||
# The port of the cache server.
|
||||
# 0 means to use a random available port.
|
||||
port: 8088
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
external_server: ""
|
||||
|
||||
container:
|
||||
# Specifies the network to which the container will connect.
|
||||
# Could be host, bridge or the name of a custom network.
|
||||
# If it's empty, act_runner will create a network automatically.
|
||||
network: ""
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
||||
options:
|
||||
|
||||
# The parent directory of a job's working directory.
|
||||
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
|
||||
# If the path starts with '/', the '/' will be trimmed.
|
||||
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
||||
# If it's empty, /workspace will be used.
|
||||
workdir_parent:
|
||||
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
||||
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
||||
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, youshould change the config to:
|
||||
# valid_volumes:
|
||||
# - data
|
||||
# - /src/*.json
|
||||
# If you want to allow any volume, please use the following configuration:
|
||||
# valid_volumes:
|
||||
# - '**'
|
||||
valid_volumes: []
|
||||
# Overrides the docker client host with the specified one.
|
||||
# If it's empty, act_runner will find an available docker host automatically.
|
||||
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||
docker_host: ""
|
||||
# Pull docker image(s) even if already present
|
||||
force_pull: false
|
||||
# Rebuild docker image(s) even if already present
|
||||
force_rebuild: false
|
||||
# Always require a reachable docker daemon, even if not required by act_runner
|
||||
require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
docker_timeout: 0s
|
||||
|
||||
host:
|
||||
# The parent directory of a job's working directory.
|
||||
# If it's empty, $HOME/.cache/act/ will be used.
|
||||
workdir_parent:
|
||||
76
scripts/setup_runner.sh
Executable file
76
scripts/setup_runner.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── usage ─────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
echo "Usage: $0 <runner-name>"
|
||||
echo " runner-name: runner-node-1 | runner-node-2 | runner-node-3"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -ne 1 ]] && usage
|
||||
|
||||
RUNNER_NAME="$1"
|
||||
|
||||
# validate
|
||||
case "$RUNNER_NAME" in
|
||||
runner-node-1|runner-node-2|runner-node-3) ;;
|
||||
*) echo "ERROR: unknown runner name '$RUNNER_NAME'"; usage ;;
|
||||
esac
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
CACHE_PORT=8088
|
||||
GITEA_URL="https://gitea.kalekber.cc/"
|
||||
REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu"
|
||||
IMAGE="docker.io/gitea/act_runner:latest"
|
||||
DATA_DIR="$PWD/data/$RUNNER_NAME"
|
||||
CFG_PATH="$DATA_DIR/config.yaml"
|
||||
|
||||
# ── detect THIS machine's LAN IP ──────────────────────────────────────────────
|
||||
HOST_IP=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
|
||||
if [[ -z "$HOST_IP" ]]; then
|
||||
echo "ERROR: could not detect host LAN IP" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Host LAN IP: $HOST_IP"
|
||||
|
||||
# ── generate config.yaml ──────────────────────────────────────────────────────
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
docker run --rm --entrypoint="" "$IMAGE" \
|
||||
act_runner generate-config > "$CFG_PATH"
|
||||
|
||||
awk -v host="$HOST_IP" -v port="$CACHE_PORT" '
|
||||
/^cache:/ { in_cache=1 }
|
||||
in_cache && /enabled:/ { $0 = " enabled: true" }
|
||||
in_cache && /dir:/ { $0 = " dir: \"/data/cache\"" }
|
||||
in_cache && /host:/ { $0 = " host: \"" host "\"" }
|
||||
in_cache && /port:/ { $0 = " port: " port; in_cache=0 }
|
||||
{ print }
|
||||
' "$CFG_PATH" > "${CFG_PATH}.tmp" && mv "${CFG_PATH}.tmp" "$CFG_PATH"
|
||||
|
||||
echo "Config written to $CFG_PATH (cache $HOST_IP:$CACHE_PORT)"
|
||||
|
||||
# ── stop + remove old container if exists ────────────────────────────────────
|
||||
if docker inspect "$RUNNER_NAME" &>/dev/null; then
|
||||
echo "Removing existing $RUNNER_NAME..."
|
||||
docker stop "$RUNNER_NAME" || true
|
||||
docker rm "$RUNNER_NAME" || true
|
||||
fi
|
||||
|
||||
# ── start runner ──────────────────────────────────────────────────────────────
|
||||
docker run \
|
||||
-v "$DATA_DIR:/data" \
|
||||
-v "$CFG_PATH:/config.yaml" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e CONFIG_FILE=/config.yaml \
|
||||
-e GITEA_INSTANCE_URL="$GITEA_URL" \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN="$REGISTRATION_TOKEN" \
|
||||
-e GITEA_RUNNER_NAME="$RUNNER_NAME" \
|
||||
-p "${CACHE_PORT}:${CACHE_PORT}" \
|
||||
--restart unless-stopped \
|
||||
--name "$RUNNER_NAME" \
|
||||
-d "$IMAGE"
|
||||
|
||||
echo "Runner $RUNNER_NAME started"
|
||||
docker ps --filter "name=$RUNNER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
20
ui/.env.example
Normal file
20
ui/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# libnovel UI — environment variables
|
||||
# Copy to .env and adjust; do NOT commit with real secrets.
|
||||
|
||||
# Public URL of the scraper API (used by SvelteKit server-side load functions)
|
||||
# In docker-compose this is the internal service name
|
||||
SCRAPER_API_URL=http://localhost:8080
|
||||
|
||||
# Public URL of PocketBase (used by SvelteKit server-side load functions)
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# PocketBase admin credentials (server-side only, never exposed to browser)
|
||||
POCKETBASE_ADMIN_EMAIL=admin@libnovel.local
|
||||
POCKETBASE_ADMIN_PASSWORD=changeme123
|
||||
|
||||
# Public-facing MinIO URL (used to rewrite presigned URLs for the browser)
|
||||
# In dev this is localhost; in prod set to your MinIO public domain
|
||||
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
|
||||
|
||||
# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32)
|
||||
AUTH_SECRET=change_this_to_a_long_random_secret
|
||||
23
ui/.gitignore
vendored
Normal file
23
ui/.gitignore
vendored
Normal 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-*
|
||||
27
ui/Dockerfile
Normal file
27
ui/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# adapter-node produces a standalone build/
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/package-lock.json ./
|
||||
|
||||
# Install production dependencies (e.g. marked) that are imported at runtime
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
EXPOSE $PORT
|
||||
CMD ["node", "build"]
|
||||
42
ui/README.md
Normal file
42
ui/README.md
Normal 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.
|
||||
2480
ui/package-lock.json
generated
Normal file
2480
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
ui/package.json
Normal file
31
ui/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^25.3.3",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^17.0.3",
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
}
|
||||
65
ui/src/app.css
Normal file
65
ui/src/app.css
Normal 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
ui/src/app.d.ts
vendored
Normal file
18
ui/src/app.d.ts
vendored
Normal 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 } | null;
|
||||
}
|
||||
interface PageData {
|
||||
user?: { id: string; username: string; role: string } | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
ui/src/app.html
Normal file
11
ui/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
96
ui/src/hooks.server.ts
Normal file
96
ui/src/hooks.server.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { randomBytes, createHmac } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
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>"
|
||||
*/
|
||||
export function createAuthToken(userId: string, username: string, role: string): string {
|
||||
return signToken(`${userId}:${username}:${role}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a verified auth token into user data. Returns null if invalid.
|
||||
*/
|
||||
export function parseAuthToken(token: string): { id: string; username: string; role: string } | null {
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) return null;
|
||||
const firstColon = payload.indexOf(':');
|
||||
if (firstColon < 0) return null;
|
||||
const secondColon = payload.indexOf(':', firstColon + 1);
|
||||
if (secondColon < 0) return null;
|
||||
const id = payload.slice(0, firstColon);
|
||||
const username = payload.slice(firstColon + 1, secondColon);
|
||||
const role = payload.slice(secondColon + 1);
|
||||
if (!id || !username) return null;
|
||||
return { id, username, role };
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// 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 = user;
|
||||
} else {
|
||||
event.locals.user = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
1
ui/src/lib/assets/favicon.svg
Normal file
1
ui/src/lib/assets/favicon.svg
Normal 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 |
146
ui/src/lib/audio.svelte.ts
Normal file
146
ui/src/lib/audio.svelte.ts
Normal 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 0–100 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 (0–100) 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();
|
||||
782
ui/src/lib/components/AudioPlayer.svelte
Normal file
782
ui/src/lib/components/AudioPlayer.svelte
Normal file
@@ -0,0 +1,782 @@
|
||||
<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, presign again and set audioUrl.
|
||||
*
|
||||
* ── 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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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 in background
|
||||
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}`);
|
||||
|
||||
stopNextProgress();
|
||||
audioStore.nextProgress = 100;
|
||||
|
||||
const url2 = await tryPresign(slug, nextChapter, voice);
|
||||
if (!url2) throw new Error('Prefetch: audio generated but presign returned 404');
|
||||
|
||||
audioStore.nextAudioUrl = url2;
|
||||
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.
|
||||
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}`);
|
||||
|
||||
await finishProgress();
|
||||
|
||||
const url2 = await tryPresign(slug, chapter, voice);
|
||||
if (!url2) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = url2;
|
||||
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
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors {showVoicePanel
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-400 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
||||
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="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
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
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="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="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
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v); }}
|
||||
class="p-1 rounded transition-colors flex-shrink-0 {samplePlayingVoice === v
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700'}"
|
||||
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
|
||||
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
|
||||
>
|
||||
{#if samplePlayingVoice === v}
|
||||
<!-- Stop icon -->
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h12v12H6z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Play icon -->
|
||||
<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'}
|
||||
<!-- Should not normally reach here while current, but handle gracefully -->
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
</button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<button
|
||||
disabled
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold opacity-50 cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<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
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors flex-shrink-0 {audioStore.autoNext
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-500 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
||||
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
|
||||
onclick={startPlayback}
|
||||
class="px-3 py-1 rounded bg-zinc-700 text-zinc-200 text-xs font-medium hover:bg-zinc-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
Load this chapter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Idle — nothing playing ── -->
|
||||
<button
|
||||
onclick={handlePlay}
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
1
ui/src/lib/index.ts
Normal file
1
ui/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
37
ui/src/lib/server/logger.ts
Normal file
37
ui/src/lib/server/logger.ts
Normal 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),
|
||||
};
|
||||
128
ui/src/lib/server/minio.ts
Normal file
128
ui/src/lib/server/minio.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Server-side MinIO presign helper.
|
||||
* Calls the scraper API to get presigned URLs, then optionally rewrites
|
||||
* the MinIO host to the public-facing URL for browser use.
|
||||
*
|
||||
* Never import this from client-side code.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as pubEnv } from '$env/dynamic/public';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
// Public MinIO URL — used to rewrite presigned URLs so the browser can reach MinIO directly.
|
||||
// In docker-compose this would differ from the internal endpoint.
|
||||
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
|
||||
|
||||
/**
|
||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
|
||||
* but the browser needs the public URL (e.g. localhost:9000 in dev, or a CDN in prod).
|
||||
* Rewriting the host preserves all query params (signature, expiry, etc).
|
||||
*/
|
||||
function rewriteHost(presignedUrl: string): string {
|
||||
try {
|
||||
const u = new URL(presignedUrl);
|
||||
const pub = new URL(MINIO_PUBLIC_URL);
|
||||
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 fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`);
|
||||
} catch (e) {
|
||||
log.error('minio', 'presign chapter network error', { slug, n, err: String(e) });
|
||||
throw new Error(`presign chapter ${slug}/${n}: network error`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('minio', 'presign chapter failed', { slug, n, status: res.status, body });
|
||||
throw new Error(`presign chapter ${slug}/${n}: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
log.debug('minio', 'presign chapter ok', { slug, n });
|
||||
return rewrite ? rewriteHost(data.url) : data.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a presigned URL for a voice sample audio file.
|
||||
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
|
||||
* Throws with { status: 404 } when the sample has not been generated yet.
|
||||
*/
|
||||
export async function presignVoiceSample(voice: string): Promise<string> {
|
||||
log.debug('minio', 'presigning voice sample', { voice });
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${SCRAPER_URL}/api/presign/voice-sample/${encodeURIComponent(voice)}`);
|
||||
} catch (e) {
|
||||
log.error('minio', 'presign voice sample network error', { voice, err: String(e) });
|
||||
throw new Error(`presign voice sample ${voice}: network error`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
const err = new Error(`presign voice sample ${voice}: not found`) as Error & { status: number };
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('minio', 'presign voice sample failed', { voice, status: res.status, body });
|
||||
throw new Error(`presign voice sample ${voice}: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
log.debug('minio', 'presign voice sample ok', { voice });
|
||||
return rewriteHost(data.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a presigned URL for an audio file.
|
||||
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
|
||||
* Throws with { status: 404 } when the audio object has not been generated yet.
|
||||
*/
|
||||
export async function presignAudio(
|
||||
slug: string,
|
||||
n: number,
|
||||
voice?: string
|
||||
): Promise<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 fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
|
||||
} catch (e) {
|
||||
log.error('minio', 'presign audio network error', { slug, n, err: String(e) });
|
||||
throw new Error(`presign audio ${slug}/${n}: network error`);
|
||||
}
|
||||
if (res.status === 404) {
|
||||
// Audio hasn't been generated / uploaded yet — caller should surface this as 404.
|
||||
const err = new Error(`presign audio ${slug}/${n}: not found`) as Error & { status: number };
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('minio', 'presign audio failed', { slug, n, status: res.status, body });
|
||||
throw new Error(`presign audio ${slug}/${n}: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
log.debug('minio', 'presign audio ok', { slug, n });
|
||||
return rewriteHost(data.url);
|
||||
}
|
||||
669
ui/src/lib/server/pocketbase.ts
Normal file
669
ui/src/lib/server/pocketbase.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* Server-side PocketBase client.
|
||||
* Uses admin credentials — never import this from client-side code.
|
||||
* All methods talk directly to PocketBase REST API.
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const PB_URL = env.POCKETBASE_URL ?? 'http://localhost:8090';
|
||||
const PB_EMAIL = env.POCKETBASE_ADMIN_EMAIL ?? 'admin@libnovel.local';
|
||||
const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[] | string;
|
||||
summary: string;
|
||||
total_chapters: number;
|
||||
source_url: string;
|
||||
ranking: number;
|
||||
meta_updated: string;
|
||||
}
|
||||
|
||||
export interface ChapterIdx {
|
||||
id: string;
|
||||
slug: string;
|
||||
number: number;
|
||||
title: string;
|
||||
date_label: string;
|
||||
}
|
||||
|
||||
export interface Progress {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
chapter: number;
|
||||
audio_time?: number;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
auto_next: boolean;
|
||||
voice: string;
|
||||
speed: number;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
|
||||
let _token = '';
|
||||
let _tokenExp = 0;
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (_token && Date.now() < _tokenExp) return _token;
|
||||
|
||||
log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL });
|
||||
|
||||
const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'admin auth failed', { status: res.status, url: PB_URL, body });
|
||||
throw new Error(`PocketBase auth failed: ${res.status} — ${body}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
_token = data.token as string;
|
||||
_tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours
|
||||
log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL });
|
||||
return _token;
|
||||
}
|
||||
|
||||
// ─── Generic helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function pbGet<T>(path: string): Promise<T> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'GET failed', { path, status: res.status, body });
|
||||
throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function pbPost(path: string, body: unknown): Promise<Response> {
|
||||
const token = await getToken();
|
||||
return fetch(`${PB_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
async function pbPatch(path: string, body: unknown): Promise<Response> {
|
||||
const token = await getToken();
|
||||
return fetch(`${PB_URL}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
interface PBList<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
async function listAll<T>(collection: string, filter = '', sort = ''): Promise<T[]> {
|
||||
const perPage = 500;
|
||||
const params = new URLSearchParams({ perPage: String(perPage), page: '1' });
|
||||
if (filter) params.set('filter', filter);
|
||||
if (sort) params.set('sort', sort);
|
||||
|
||||
const first = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
const items: T[] = first.items ?? [];
|
||||
const total = first.totalItems ?? 0;
|
||||
|
||||
// Fetch remaining pages if there are more records than the first page holds.
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
for (let page = 2; page <= totalPages; page++) {
|
||||
params.set('page', String(page));
|
||||
const data = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
items.push(...(data.items ?? []));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function listN<T>(collection: string, n: number, filter = '', sort = ''): Promise<T[]> {
|
||||
const params = new URLSearchParams({ perPage: String(n) });
|
||||
if (filter) params.set('filter', filter);
|
||||
if (sort) params.set('sort', sort);
|
||||
const data = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
async function countCollection(collection: string, filter = ''): Promise<number> {
|
||||
const params = new URLSearchParams({ perPage: '1' });
|
||||
if (filter) params.set('filter', filter);
|
||||
const data = await pbGet<PBList<unknown>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
return (data as { totalItems: number }).totalItems ?? 0;
|
||||
}
|
||||
|
||||
async function listOne<T>(collection: string, filter: string): Promise<T | null> {
|
||||
const params = new URLSearchParams({ perPage: '1', filter });
|
||||
const data = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
return data.items[0] ?? null;
|
||||
}
|
||||
|
||||
// ─── Books ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listBooks(): Promise<Book[]> {
|
||||
const books = await listAll<Book>('books', '', '+title');
|
||||
const nullTitles = books.filter((b) => b.title == null).length;
|
||||
if (nullTitles > 0) {
|
||||
log.warn('pocketbase', 'listBooks: books with null title', { count: nullTitles, total: books.length });
|
||||
}
|
||||
log.debug('pocketbase', 'listBooks', { total: books.length, nullTitles });
|
||||
return books;
|
||||
}
|
||||
|
||||
export async function getBook(slug: string): Promise<Book | null> {
|
||||
return listOne<Book>('books', `slug="${slug}"`);
|
||||
}
|
||||
|
||||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
}
|
||||
|
||||
export async function recentlyUpdatedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
}
|
||||
|
||||
export interface HomeStats {
|
||||
totalBooks: number;
|
||||
totalChapters: number;
|
||||
}
|
||||
|
||||
export async function getHomeStats(): Promise<HomeStats> {
|
||||
const [totalBooks, totalChapters] = await Promise.all([
|
||||
countCollection('books'),
|
||||
countCollection('chapters_idx')
|
||||
]);
|
||||
return { totalBooks, totalChapters };
|
||||
}
|
||||
|
||||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listChapterIdx(slug: string): Promise<ChapterIdx[]> {
|
||||
return listAll<ChapterIdx>('chapters_idx', `slug="${slug}"`, '+number');
|
||||
}
|
||||
|
||||
// ─── Reading progress ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the PocketBase filter string for a progress lookup.
|
||||
* When userId is set, keyed by user_id (portable across devices).
|
||||
* When only sessionId is set, keyed by session_id (anonymous).
|
||||
*/
|
||||
function progressFilter(sessionId: string, slug: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"&&slug="${slug}"`;
|
||||
return `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
}
|
||||
|
||||
function allProgressFilter(sessionId: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"`;
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
export async function getProgress(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<Progress | null> {
|
||||
return listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
|
||||
}
|
||||
|
||||
export async function allProgress(sessionId: string, userId?: string): Promise<Progress[]> {
|
||||
return listAll<Progress>('progress', allProgressFilter(sessionId, userId), '-updated');
|
||||
}
|
||||
|
||||
export async function setProgress(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
chapter: number,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<Progress & { id: string }>(
|
||||
'progress',
|
||||
progressFilter(sessionId, slug, userId)
|
||||
);
|
||||
|
||||
const payload: Partial<Progress> = {
|
||||
session_id: sessionId,
|
||||
slug,
|
||||
chapter,
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, payload);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'setProgress PATCH failed', { slug, chapter, status: res.status, body });
|
||||
}
|
||||
} else {
|
||||
const res = await pbPost('/api/collections/progress/records', payload);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge anonymous session progress into a user account on login/register.
|
||||
*
|
||||
* For each book tracked under sessionId, upserts a user-keyed record keeping
|
||||
* whichever chapter is more recent (or higher if timestamps are equal).
|
||||
* This makes progress portable across devices for logged-in users.
|
||||
*/
|
||||
export async function mergeSessionProgress(sessionId: string, userId: string): Promise<void> {
|
||||
let sessionRows: Progress[];
|
||||
try {
|
||||
sessionRows = await allProgress(sessionId);
|
||||
} catch (e) {
|
||||
log.warn('pocketbase', 'mergeSessionProgress: failed to read session progress', {
|
||||
sessionId,
|
||||
err: String(e)
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sessionRows.length === 0) return;
|
||||
|
||||
for (const row of sessionRows) {
|
||||
try {
|
||||
const userRow = await listOne<Progress & { id: string }>(
|
||||
'progress',
|
||||
`user_id="${userId}"&&slug="${row.slug}"`
|
||||
);
|
||||
|
||||
// Keep the record with the more recent update (or higher chapter if timestamps match)
|
||||
const sessionTs = row.updated ? new Date(row.updated).getTime() : 0;
|
||||
const userTs = userRow?.updated ? new Date(userRow.updated).getTime() : 0;
|
||||
const shouldOverwrite = !userRow || sessionTs > userTs ||
|
||||
(sessionTs === userTs && row.chapter > (userRow?.chapter ?? 0));
|
||||
|
||||
if (shouldOverwrite) {
|
||||
const payload: Partial<Progress> = {
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
slug: row.slug,
|
||||
chapter: row.chapter,
|
||||
updated: row.updated ?? new Date().toISOString()
|
||||
};
|
||||
if (userRow) {
|
||||
await pbPatch(`/api/collections/progress/records/${userRow.id}`, payload);
|
||||
} else {
|
||||
await pbPost('/api/collections/progress/records', payload);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('pocketbase', 'mergeSessionProgress: failed to merge row', {
|
||||
slug: row.slug,
|
||||
err: String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
log.info('pocketbase', 'mergeSessionProgress: done', { sessionId, userId, count: sessionRows.length });
|
||||
}
|
||||
|
||||
// ─── User library (saved books) ───────────────────────────────────────────────
|
||||
|
||||
export interface UserLibraryEntry {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function libraryFilter(sessionId: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"`;
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
/** Returns all slugs the user has explicitly saved to their library. */
|
||||
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const rows = await listAll<UserLibraryEntry>(
|
||||
'user_library',
|
||||
libraryFilter(sessionId, userId)
|
||||
);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
}
|
||||
|
||||
/** Returns whether a specific slug is saved. */
|
||||
export async function isBookSaved(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<boolean> {
|
||||
const filter = userId
|
||||
? `user_id="${userId}"&&slug="${slug}"`
|
||||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
const row = await listOne<UserLibraryEntry>('user_library', filter);
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
/** Save a book to the user's library. No-op if already saved. */
|
||||
export async function saveBook(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const alreadySaved = await isBookSaved(sessionId, slug, userId);
|
||||
if (alreadySaved) return;
|
||||
const payload: Partial<UserLibraryEntry> = {
|
||||
session_id: sessionId,
|
||||
slug,
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
if (userId) payload.user_id = userId;
|
||||
const res = await pbPost('/api/collections/user_library/records', payload);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a book from the user's library. */
|
||||
export async function unsaveBook(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `user_id="${userId}"&&slug="${slug}"`
|
||||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
const row = await listOne<UserLibraryEntry & { id: string }>('user_library', filter);
|
||||
if (!row) return;
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_library/records/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const derived = scryptSync(password, salt, 64);
|
||||
const hashBuf = Buffer.from(hash, 'hex');
|
||||
if (derived.length !== hashBuf.length) return false;
|
||||
return timingSafeEqual(derived, hashBuf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by username. Returns null if not found.
|
||||
*/
|
||||
export async function getUserByUsername(username: string): Promise<User | null> {
|
||||
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with a hashed password. Throws if username already exists.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role = 'user'): Promise<User> {
|
||||
log.info('pocketbase', 'createUser: checking for existing username', { username });
|
||||
const existing = await getUserByUsername(username);
|
||||
if (existing) {
|
||||
log.warn('pocketbase', 'createUser: username already taken', { username });
|
||||
throw new Error('Username already taken');
|
||||
}
|
||||
const password_hash = hashPassword(password);
|
||||
log.info('pocketbase', 'createUser: inserting new user', { username, role });
|
||||
const res = await pbPost('/api/collections/app_users/records', {
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
created: new Date().toISOString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'createUser: PocketBase rejected record', {
|
||||
username,
|
||||
status: res.status,
|
||||
body
|
||||
});
|
||||
throw new Error(`Failed to create user: ${res.status} ${body}`);
|
||||
}
|
||||
log.info('pocketbase', 'createUser: user created', { username, role });
|
||||
return res.json() as Promise<User>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a user's password. Verifies the current password first.
|
||||
* Returns true on success, false if currentPassword is wrong.
|
||||
* Throws on unexpected errors.
|
||||
*/
|
||||
export async function changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<boolean> {
|
||||
// Fetch the user record directly by id to verify current password
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to fetch user: ${res.status}`);
|
||||
}
|
||||
const user = (await res.json()) as User;
|
||||
if (!verifyPassword(currentPassword, user.password_hash)) {
|
||||
log.warn('pocketbase', 'changePassword: wrong current password', { userId });
|
||||
return false;
|
||||
}
|
||||
const newHash = hashPassword(newPassword);
|
||||
const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||||
password_hash: newHash
|
||||
});
|
||||
if (!patch.ok) {
|
||||
const body = await patch.text().catch(() => '');
|
||||
log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body });
|
||||
throw new Error(`Failed to update password: ${patch.status}`);
|
||||
}
|
||||
log.info('pocketbase', 'changePassword: success', { userId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify username + password. Returns the user on success, null on failure.
|
||||
*/
|
||||
export async function loginUser(username: string, password: string): Promise<User | null> {
|
||||
log.debug('pocketbase', 'loginUser: lookup', { username });
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) {
|
||||
log.warn('pocketbase', 'loginUser: username not found', { username });
|
||||
return null;
|
||||
}
|
||||
const ok = verifyPassword(password, user.password_hash);
|
||||
if (!ok) {
|
||||
log.warn('pocketbase', 'loginUser: wrong password', { username });
|
||||
return null;
|
||||
}
|
||||
log.info('pocketbase', 'loginUser: success', { username, role: user.role });
|
||||
return user;
|
||||
}
|
||||
|
||||
// ─── User settings ────────────────────────────────────────────────────────────
|
||||
|
||||
function settingsFilter(sessionId: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"`;
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
export async function getSettings(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<UserSettings | null> {
|
||||
return listOne<UserSettings>('user_settings', settingsFilter(sessionId, userId));
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
sessionId: string,
|
||||
settings: { autoNext: boolean; voice: string; speed: number },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<UserSettings & { id: string }>(
|
||||
'user_settings',
|
||||
settingsFilter(sessionId, userId)
|
||||
);
|
||||
|
||||
const payload: Partial<UserSettings> = {
|
||||
session_id: sessionId,
|
||||
auto_next: settings.autoNext,
|
||||
voice: settings.voice,
|
||||
speed: settings.speed,
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
const res = await pbPatch(`/api/collections/user_settings/records/${existing.id}`, payload);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveSettings PATCH failed', { status: res.status, body });
|
||||
}
|
||||
} else {
|
||||
const res = await pbPost('/api/collections/user_settings/records', payload);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveSettings POST failed', { status: res.status, body });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audio time ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function setAudioTime(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
chapter: number,
|
||||
audioTime: number,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<Progress & { id: string }>(
|
||||
'progress',
|
||||
progressFilter(sessionId, slug, userId)
|
||||
);
|
||||
if (!existing) {
|
||||
// No progress record yet — create one with audio_time
|
||||
const payload: Partial<Progress> = {
|
||||
session_id: sessionId,
|
||||
slug,
|
||||
chapter,
|
||||
audio_time: audioTime,
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
if (userId) payload.user_id = userId;
|
||||
const res = await pbPost('/api/collections/progress/records', payload);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'setAudioTime POST failed', { slug, chapter, status: res.status, body });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, {
|
||||
audio_time: audioTime,
|
||||
updated: new Date().toISOString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'setAudioTime PATCH failed', { slug, chapter, status: res.status, body });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audio cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AudioCacheEntry {
|
||||
id: string;
|
||||
cache_key: string;
|
||||
filename: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
|
||||
return listAll<AudioCacheEntry>('audio_cache', '', '-updated');
|
||||
}
|
||||
|
||||
// ─── Scraping tasks ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScrapingTask {
|
||||
id: string;
|
||||
kind: string;
|
||||
target_url: string;
|
||||
status: string;
|
||||
books_found: number;
|
||||
chapters_scraped: number;
|
||||
chapters_skipped: number;
|
||||
errors: number;
|
||||
started: string;
|
||||
finished: string;
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
export async function listScrapingTasks(): Promise<ScrapingTask[]> {
|
||||
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
|
||||
}
|
||||
|
||||
export async function getAudioTime(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
chapter: number,
|
||||
userId?: string
|
||||
): Promise<number | null> {
|
||||
const row = await listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
|
||||
if (!row || !row.audio_time) return null;
|
||||
return row.audio_time;
|
||||
}
|
||||
32
ui/src/routes/+layout.server.ts
Normal file
32
ui/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
const PUBLIC_ROUTES = new Set(['/login']);
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 };
|
||||
try {
|
||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||
if (row) {
|
||||
settings = {
|
||||
autoNext: row.auto_next ?? false,
|
||||
voice: row.voice ?? 'af_bella',
|
||||
speed: row.speed ?? 1.0
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('layout', 'failed to load settings', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
settings
|
||||
};
|
||||
};
|
||||
598
ui/src/routes/+layout.svelte
Normal file
598
ui/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,598 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
// Mobile nav drawer state
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
|
||||
// The single <audio> element that persists across navigations.
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
// Apply persisted settings once on mount (server-loaded data).
|
||||
let settingsApplied = false;
|
||||
$effect(() => {
|
||||
if (!settingsApplied && data.settings) {
|
||||
settingsApplied = true;
|
||||
audioStore.autoNext = data.settings.autoNext;
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Persist settings changes (debounced 800ms) ──────────────────────────
|
||||
let settingsSaveTimer = 0;
|
||||
$effect(() => {
|
||||
// Subscribe to the three settings fields
|
||||
const autoNext = audioStore.autoNext;
|
||||
const voice = audioStore.voice;
|
||||
const speed = audioStore.speed;
|
||||
|
||||
// Skip saving until settings have been applied from the server
|
||||
if (!settingsApplied) return;
|
||||
|
||||
clearTimeout(settingsSaveTimer);
|
||||
settingsSaveTimer = setTimeout(() => {
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed })
|
||||
}).catch(() => {});
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
|
||||
// Keep the audio element's playback rate in sync with the store speed.
|
||||
$effect(() => {
|
||||
if (audioEl) audioEl.playbackRate = audioStore.speed;
|
||||
});
|
||||
|
||||
// When audioUrl changes, load the new source.
|
||||
// Use a local variable to track which URL is currently loaded so we never
|
||||
// compare against audioEl.src (browsers normalise it, causing false mismatches).
|
||||
let loadedUrl = '';
|
||||
$effect(() => {
|
||||
if (!audioEl) return;
|
||||
const url = audioStore.audioUrl;
|
||||
if (url && url !== loadedUrl) {
|
||||
loadedUrl = url;
|
||||
audioEl.src = url;
|
||||
audioEl.load();
|
||||
audioEl.playbackRate = audioStore.speed;
|
||||
audioEl.play().catch(() => {});
|
||||
} else if (!url) {
|
||||
loadedUrl = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle toggle requests from AudioPlayer controller.
|
||||
$effect(() => {
|
||||
// Read toggleRequest to subscribe; ignore value 0 (initial).
|
||||
const _req = audioStore.toggleRequest;
|
||||
if (!audioEl || _req === 0) return;
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl.pause();
|
||||
} else {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle seek requests from AudioPlayer controller.
|
||||
$effect(() => {
|
||||
const t = audioStore.seekRequest;
|
||||
if (!audioEl || t === null) return;
|
||||
audioEl.currentTime = t;
|
||||
audioStore.seekRequest = null;
|
||||
});
|
||||
|
||||
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||
let audioTimeSaveTimer = 0;
|
||||
function saveAudioTime() {
|
||||
if (!audioStore.slug || !audioStore.chapter) return;
|
||||
const slug = audioStore.slug;
|
||||
const chapter = audioStore.chapter;
|
||||
const currentTime = audioStore.currentTime;
|
||||
clearTimeout(audioTimeSaveTimer);
|
||||
audioTimeSaveTimer = setTimeout(() => {
|
||||
fetch('/api/progress/audio-time', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, chapter, audioTime: currentTime })
|
||||
}).catch(() => {});
|
||||
}, 2000) as unknown as number;
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!audioEl) return;
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl.pause();
|
||||
} else {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function seek(e: Event) {
|
||||
if (!audioEl) return;
|
||||
audioEl.currentTime = parseFloat((e.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function skipBack() {
|
||||
if (!audioEl) return;
|
||||
audioEl.currentTime = Math.max(0, audioEl.currentTime - 15);
|
||||
}
|
||||
|
||||
function skipForward() {
|
||||
if (!audioEl) return;
|
||||
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
|
||||
}
|
||||
|
||||
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
|
||||
function cycleSpeed() {
|
||||
const idx = speedSteps.indexOf(audioStore.speed);
|
||||
audioStore.speed = speedSteps[(idx + 1) % speedSteps.length];
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
audioEl.src = '';
|
||||
}
|
||||
audioStore.status = 'idle';
|
||||
audioStore.audioUrl = '';
|
||||
audioStore.slug = '';
|
||||
audioStore.chapter = 0;
|
||||
audioStore.isPlaying = false;
|
||||
audioStore.currentTime = 0;
|
||||
audioStore.duration = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Persistent audio element — always in the DOM, never conditionally unmounted.
|
||||
Conditional rendering ({#if}) would destroy/recreate it when reactive state
|
||||
changes (e.g. currentTime ticking), triggering onpause and restarting audio. -->
|
||||
<audio
|
||||
bind:this={audioEl}
|
||||
bind:currentTime={audioStore.currentTime}
|
||||
bind:duration={audioStore.duration}
|
||||
onplay={() => (audioStore.isPlaying = true)}
|
||||
onpause={() => {
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
}}
|
||||
onended={() => {
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||
// Capture values synchronously before any async work — the AudioPlayer
|
||||
// component will unmount during navigation, but we've already read what
|
||||
// we need.
|
||||
const targetSlug = audioStore.slug;
|
||||
const targetChapter = audioStore.nextChapter;
|
||||
// Store the target chapter number so only the newly-mounted AudioPlayer
|
||||
// for that chapter reacts — not the outgoing chapter's component.
|
||||
audioStore.autoStartChapter = targetChapter;
|
||||
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
||||
audioStore.autoStartChapter = null;
|
||||
});
|
||||
}
|
||||
}}
|
||||
preload="metadata"
|
||||
style="display:none"
|
||||
></audio>
|
||||
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
|
||||
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
|
||||
{#if navigating}
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-zinc-800">
|
||||
<div class="h-full bg-amber-400 animate-progress-bar"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
|
||||
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
|
||||
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300">
|
||||
libnovel
|
||||
</a>
|
||||
|
||||
{#if data.user}
|
||||
<!-- Desktop nav links (hidden on mobile) -->
|
||||
<a
|
||||
href="/books"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/browse"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/browse') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Discover
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Scrape
|
||||
</a>
|
||||
<a
|
||||
href="/admin/audio"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Audio cache
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/profile"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
{data.user.username}
|
||||
</a>
|
||||
<form method="POST" action="/logout" class="hidden sm:block">
|
||||
<button type="submit" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Mobile: hamburger button -->
|
||||
<button
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={menuOpen}
|
||||
class="sm:hidden p-2 -mr-1 rounded text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
{#if menuOpen}
|
||||
<!-- X icon -->
|
||||
<svg class="w-5 h-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>
|
||||
{:else}
|
||||
<!-- Hamburger icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
href="/login"
|
||||
class="text-sm px-3 py-1.5 rounded bg-amber-400 text-zinc-900 font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Mobile drawer (full-width, below the bar) -->
|
||||
{#if data.user && menuOpen}
|
||||
<div class="sm:hidden border-t border-zinc-700 bg-zinc-900 px-4 py-3 flex flex-col gap-1">
|
||||
<a
|
||||
href="/books"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/browse"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/browse') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Discover
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Profile <span class="text-zinc-500 font-normal">({data.user.username})</span>
|
||||
</a>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="my-1 border-t border-zinc-700/60"></div>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-zinc-600 uppercase tracking-widest">Admin</p>
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Scrape tasks
|
||||
</a>
|
||||
<a
|
||||
href="/admin/audio"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Audio cache
|
||||
</a>
|
||||
{/if}
|
||||
<div class="my-1 border-t border-zinc-700/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-left px-3 py-2.5 rounded-lg text-sm font-medium text-red-400 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
|
||||
{#key page.url.pathname + page.url.search}
|
||||
{@render children()}
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-zinc-800 mt-auto">
|
||||
<div class="max-w-6xl mx-auto px-4 py-5 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<span class="text-zinc-500 text-sm font-semibold tracking-tight">libnovel</span>
|
||||
<nav class="flex items-center gap-5 text-xs text-zinc-600">
|
||||
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
|
||||
<a href="/browse" class="hover:text-zinc-400 transition-colors">Discover</a>
|
||||
<a
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
|
||||
>
|
||||
novelfire.net
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
|
||||
{#if audioStore.active}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl">
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-zinc-700 bg-zinc-900 max-h-64 overflow-y-auto">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
|
||||
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span>
|
||||
<button
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="text-zinc-600 hover:text-zinc-300 transition-colors p-1"
|
||||
aria-label="Close chapter list"
|
||||
>
|
||||
<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="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter
|
||||
? 'text-amber-400 font-semibold'
|
||||
: 'text-zinc-400'}"
|
||||
>
|
||||
<span class="tabular-nums text-zinc-600 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<div class="h-0.5 bg-zinc-800">
|
||||
<div
|
||||
class="h-full bg-amber-400 transition-none"
|
||||
style="width: {audioStore.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<!-- Seek bar flush at top — tappable on mobile -->
|
||||
<div class="px-0">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1 accent-amber-400 cursor-pointer block"
|
||||
style="margin: 0; border-radius: 0;"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
|
||||
|
||||
<!-- Track info (click to open chapter list drawer) -->
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-zinc-800 transition-colors"
|
||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
||||
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined}
|
||||
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined}
|
||||
>
|
||||
{#if audioStore.chapterTitle}
|
||||
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
|
||||
{/if}
|
||||
{#if audioStore.bookTitle}
|
||||
<p class="text-xs text-zinc-500 truncate leading-tight">{audioStore.bookTitle}</p>
|
||||
{/if}
|
||||
{#if audioStore.status === 'generating'}
|
||||
<p class="text-xs text-amber-400 leading-tight">
|
||||
Generating… {Math.round(audioStore.progress)}%
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-zinc-500 tabular-nums leading-tight">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</p>
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<p class="text-xs text-zinc-500 leading-tight">Loading…</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if audioStore.status === 'ready'}
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
onclick={skipBack}
|
||||
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
|
||||
title="Back 15s"
|
||||
aria-label="Rewind 15 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<button
|
||||
onclick={togglePlay}
|
||||
class="w-10 h-10 rounded-full bg-amber-400 text-zinc-900 flex items-center justify-center hover:bg-amber-300 transition-colors flex-shrink-0"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
onclick={skipForward}
|
||||
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
|
||||
title="Forward 30s"
|
||||
aria-label="Skip 30 seconds"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Speed control -->
|
||||
<button
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-semibold text-zinc-300 hover:text-amber-400 transition-colors px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 flex-shrink-0 tabular-nums w-12 text-center"
|
||||
title="Change playback speed"
|
||||
aria-label="Playback speed {audioStore.speed}x"
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
|
||||
<!-- Auto-next toggle (with prefetch indicator) -->
|
||||
<button
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class="relative p-1.5 rounded flex-shrink-0 transition-colors {audioStore.autoNext
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
|
||||
title={audioStore.autoNext
|
||||
? audioStore.nextStatus === 'prefetched'
|
||||
? `Auto-next on — Ch.${audioStore.nextChapter} ready`
|
||||
: audioStore.nextStatus === 'prefetching'
|
||||
? `Auto-next on — preparing Ch.${audioStore.nextChapter}…`
|
||||
: 'Auto-next on'
|
||||
: 'Auto-next off'}
|
||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
<!-- Prefetch status dot -->
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<!-- Spinner during generation -->
|
||||
<svg class="w-6 h-6 text-amber-400 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>
|
||||
{/if}
|
||||
|
||||
<!-- Cover thumbnail / go-to-chapter link -->
|
||||
{#if audioStore.slug && audioStore.chapter > 0}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}"
|
||||
class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity"
|
||||
title="Go to chapter"
|
||||
aria-label="Go to chapter"
|
||||
>
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
src={audioStore.cover}
|
||||
alt=""
|
||||
class="w-8 h-11 object-cover rounded"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Fallback book icon -->
|
||||
<div class="w-8 h-11 flex items-center justify-center bg-zinc-800 rounded border border-zinc-700">
|
||||
<svg class="w-4 h-4 text-zinc-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Dismiss -->
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="text-zinc-600 hover:text-zinc-400 transition-colors p-1.5 rounded flex-shrink-0"
|
||||
title="Close player"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<svg class="w-4 h-4" 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>
|
||||
{/if}
|
||||
49
ui/src/routes/+page.server.ts
Normal file
49
ui/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
listBooks,
|
||||
recentlyAddedBooks,
|
||||
allProgress,
|
||||
getHomeStats
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let allBooks: Book[] = [];
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
|
||||
try {
|
||||
[allBooks, recentBooks, progressList, stats] = await Promise.all([
|
||||
listBooks(),
|
||||
recentlyAddedBooks(8),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getHomeStats()
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
// Build slug → book lookup
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Continue reading: progress entries joined with book data, most recent first
|
||||
const continueReading = progressList
|
||||
.filter((p) => bookMap.has(p.slug))
|
||||
.slice(0, 6)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
|
||||
// Recently updated: deduplicate against continueReading slugs
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
recentlyUpdated,
|
||||
stats: {
|
||||
...stats,
|
||||
booksInProgress: continueReading.length
|
||||
}
|
||||
};
|
||||
};
|
||||
149
ui/src/routes/+page.svelte
Normal file
149
ui/src/routes/+page.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="flex gap-6 mb-8 text-center">
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">Books</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">Chapters</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">In progress</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Reading -->
|
||||
{#if data.continueReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">Continue Reading</h2>
|
||||
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.continueReading as { book, chapter }}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{chapter}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Chapter badge overlay -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Recently Updated -->
|
||||
{#if data.recentlyUpdated.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">Recently Updated</h2>
|
||||
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.recentlyUpdated as book}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 self-start">{book.status}</span>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg font-semibold text-zinc-300 mb-2">Your library is empty</p>
|
||||
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
|
||||
<a
|
||||
href="/browse"
|
||||
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Discover Novels
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
17
ui/src/routes/admin/audio/+page.server.ts
Normal file
17
ui/src/routes/admin/audio/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listAudioCache } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const entries = await listAudioCache().catch((e) => {
|
||||
log.warn('admin/audio', 'failed to load audio cache', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
return { entries };
|
||||
};
|
||||
92
ui/src/routes/admin/audio/+page.svelte
Normal file
92
ui/src/routes/admin/audio/+page.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let entries = $state(data.entries);
|
||||
|
||||
// ── Parse cache_key ─────────────────────────────────────────────────────────
|
||||
// cache_key format: "slug/chapter/voice"
|
||||
function parseKey(key: string) {
|
||||
const parts = key.split('/');
|
||||
if (parts.length >= 3) {
|
||||
return { slug: parts[0], chapter: parts[1], voice: parts.slice(2).join('/') };
|
||||
}
|
||||
return { slug: key, chapter: '—', voice: '—' };
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
let q = $state('');
|
||||
let filtered = $derived(
|
||||
q.trim()
|
||||
? entries.filter((e) => e.cache_key.toLowerCase().includes(q.toLowerCase().trim()))
|
||||
: entries
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audio cache — libnovel admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Audio cache</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">{entries.length} cached audio file{entries.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by slug, chapter or voice…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{q.trim() ? 'No results.' : 'Audio cache is empty.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-left">Chapter</th>
|
||||
<th class="px-4 py-3 text-left">Voice</th>
|
||||
<th class="px-4 py-3 text-left">Filename</th>
|
||||
<th class="px-4 py-3 text-left">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
{#each filtered as entry}
|
||||
{@const parts = parseKey(entry.cache_key)}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a
|
||||
href="/books/{parts.slug}"
|
||||
class="hover:text-amber-400 transition-colors"
|
||||
>
|
||||
{parts.slug}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
|
||||
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
|
||||
{entry.filename}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{fmtDate(entry.updated)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
29
ui/src/routes/admin/scrape/+page.server.ts
Normal file
29
ui/src/routes/admin/scrape/+page.server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listScrapingTasks } from '$lib/server/pocketbase';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const [tasks, statusRes] = await Promise.all([
|
||||
listScrapingTasks().catch((e) => {
|
||||
log.warn('admin/scrape', 'failed to load tasks', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
fetch(`${SCRAPER_URL}/api/scrape/status`).catch(() => null)
|
||||
]);
|
||||
|
||||
let running = false;
|
||||
if (statusRes?.ok) {
|
||||
const body = await statusRes.json().catch(() => null);
|
||||
running = body?.running ?? false;
|
||||
}
|
||||
|
||||
return { tasks, running };
|
||||
};
|
||||
195
ui/src/routes/admin/scrape/+page.svelte
Normal file
195
ui/src/routes/admin/scrape/+page.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Live-poll status ────────────────────────────────────────────────────────
|
||||
let running = $state(data.running);
|
||||
let tasks = $state(data.tasks);
|
||||
let polling = $state(false);
|
||||
|
||||
// Poll every 5 s while a job is running
|
||||
$effect(() => {
|
||||
if (!running) return;
|
||||
const id = setInterval(async () => {
|
||||
const res = await fetch('/api/admin/scrape').catch(() => null);
|
||||
if (res?.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
running = body?.running ?? false;
|
||||
if (!running) {
|
||||
// Refresh tasks list once job finishes
|
||||
await invalidateAll();
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Keep local state in sync when server re-loads
|
||||
$effect(() => {
|
||||
running = data.running;
|
||||
tasks = data.tasks;
|
||||
});
|
||||
|
||||
// ── Trigger scrape ──────────────────────────────────────────────────────────
|
||||
let scrapeUrl = $state('');
|
||||
let scrapeError = $state('');
|
||||
let scraping = $state(false);
|
||||
|
||||
async function triggerScrape(url?: string) {
|
||||
if (running || scraping) return;
|
||||
scraping = true;
|
||||
scrapeError = '';
|
||||
try {
|
||||
const body = url ? { url } : {};
|
||||
const res = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
scrapeError = data.error ?? data.message ?? `Error ${res.status}`;
|
||||
} else {
|
||||
running = true;
|
||||
if (url) scrapeUrl = '';
|
||||
}
|
||||
} catch {
|
||||
scrapeError = 'Network error.';
|
||||
} finally {
|
||||
scraping = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-amber-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-red-400';
|
||||
if (status === 'cancelled') return 'text-zinc-400';
|
||||
return 'text-zinc-300';
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function duration(started: string, finished: string) {
|
||||
if (!started || !finished) return '—';
|
||||
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Scrape tasks — libnovel admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
Job status:
|
||||
{#if running}
|
||||
<span class="text-amber-400 font-medium animate-pulse">Running</span>
|
||||
{:else}
|
||||
<span class="text-green-400 font-medium">Idle</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Trigger controls -->
|
||||
<div class="flex flex-wrap gap-3 items-start">
|
||||
<button
|
||||
onclick={() => triggerScrape()}
|
||||
disabled={running || scraping}
|
||||
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Full catalogue scrape
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single book scrape -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/..."
|
||||
class="flex-1 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerScrape(scrapeUrl.trim() || undefined)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Scrape
|
||||
</button>
|
||||
</div>
|
||||
{#if scrapeError}
|
||||
<p class="text-sm text-red-400">{scrapeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tasks table -->
|
||||
{#if tasks.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">No scrape tasks yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Kind</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-right">Books</th>
|
||||
<th class="px-4 py-3 text-right">Chapters</th>
|
||||
<th class="px-4 py-3 text-right">Skipped</th>
|
||||
<th class="px-4 py-3 text-right">Errors</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
{#each tasks as task}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
|
||||
{task.kind}
|
||||
{#if task.target_url}
|
||||
<br />
|
||||
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
|
||||
{task.target_url.replace('https://novelfire.net/book/', '')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{fmtDate(task.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{duration(task.started, task.finished)}</td>
|
||||
</tr>
|
||||
{#if task.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="8" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
23
ui/src/routes/api/admin/scrape/+server.ts
Normal file
23
ui/src/routes/api/admin/scrape/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/admin/scrape/status
|
||||
* Admin-only proxy to the Go scraper's /api/scrape/status endpoint.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/scrape/status`);
|
||||
if (!res.ok) return json({ running: false });
|
||||
const data = await res.json();
|
||||
return json({ running: data.running ?? false });
|
||||
} catch {
|
||||
return json({ running: false });
|
||||
}
|
||||
};
|
||||
91
ui/src/routes/api/audio/[slug]/[n]/+server.ts
Normal file
91
ui/src/routes/api/audio/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* POST /api/audio/[slug]/[n]
|
||||
* Proxies the audio generation request to the scraper's /api/audio endpoint.
|
||||
* Keeps the scraper URL server-side — the browser never needs to know it.
|
||||
*
|
||||
* Body: { voice?: string }
|
||||
* Response: { url: string, filename: string }
|
||||
* where `url` is a relative path to GET /api/audio/[slug]/[n]?voice=...
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
if (!slug || !chapter || chapter < 1) {
|
||||
error(400, 'Invalid slug or chapter number');
|
||||
}
|
||||
|
||||
let body: { voice?: string } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
// empty body is fine — scraper will use defaults
|
||||
}
|
||||
|
||||
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!scraperRes.ok) {
|
||||
const text = await scraperRes.text().catch(() => '');
|
||||
log.error('audio', 'scraper audio generation failed', { slug, chapter, status: scraperRes.status, body: text });
|
||||
error(scraperRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
|
||||
}
|
||||
|
||||
const data = (await scraperRes.json()) as { url: string; filename: string };
|
||||
|
||||
// The scraper returns a proxy URL pointing to /api/audio-proxy/... — we rewrite
|
||||
// it to our own /api/audio/[slug]/[n]?... so the browser never calls the scraper directly.
|
||||
const voice = body.voice ?? '';
|
||||
const qs = new URLSearchParams();
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
url: `/api/audio/${slug}/${chapter}?${qs.toString()}`,
|
||||
filename: data.filename
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/audio/[slug]/[n]?voice=...
|
||||
* Proxies the audio stream from the scraper's /api/audio-proxy endpoint.
|
||||
* This is the URL the browser's <audio> element uses as its src.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
if (!slug || !chapter || chapter < 1) {
|
||||
error(400, 'Invalid slug or chapter number');
|
||||
}
|
||||
|
||||
const voice = url.searchParams.get('voice') ?? '';
|
||||
const qs = new URLSearchParams();
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`);
|
||||
|
||||
if (!scraperRes.ok) {
|
||||
log.error('audio', 'scraper audio proxy failed', { slug, chapter, status: scraperRes.status });
|
||||
error(scraperRes.status as Parameters<typeof error>[0], 'Audio not found');
|
||||
}
|
||||
|
||||
// Stream the audio body through — preserve Content-Type and Content-Length.
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', scraperRes.headers.get('Content-Type') ?? 'audio/mpeg');
|
||||
headers.set('Cache-Control', 'public, max-age=3600');
|
||||
const cl = scraperRes.headers.get('Content-Length');
|
||||
if (cl) headers.set('Content-Length', cl);
|
||||
|
||||
return new Response(scraperRes.body, { headers });
|
||||
};
|
||||
33
ui/src/routes/api/audio/voice-samples/+server.ts
Normal file
33
ui/src/routes/api/audio/voice-samples/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* POST /api/audio/voice-samples
|
||||
* Triggers generation of voice sample audio files for all (or specified) voices.
|
||||
* Proxies to the scraper's POST /api/audio/voice-samples endpoint.
|
||||
* Optional body: { voices: string[] } to generate a subset.
|
||||
* Returns: { generated: string[], skipped: string[], failed: string[] }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: { voices?: string[] } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
// Empty body is fine — generates all voices
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/audio/voice-samples`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
return json(data, { status: res.ok ? 200 : res.status });
|
||||
} catch (e) {
|
||||
return json({ error: String(e) }, { status: 502 });
|
||||
}
|
||||
};
|
||||
37
ui/src/routes/api/browse-page/+server.ts
Normal file
37
ui/src/routes/api/browse-page/+server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/browse-page?page=2&genre=all&sort=popular&status=all
|
||||
*
|
||||
* Thin proxy to the Go scraper's /api/browse endpoint.
|
||||
* Used by the infinite-scroll browse page to append subsequent pages
|
||||
* without a full SSR navigation.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const page = url.searchParams.get('page') ?? '1';
|
||||
const genre = url.searchParams.get('genre') ?? 'all';
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
|
||||
const params = new URLSearchParams({ page, genre, sort, status });
|
||||
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('browse-page', 'scraper returned error', { status: res.status });
|
||||
throw error(502, `Browse fetch failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return json(data);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('browse-page', 'network error', { err: String(e) });
|
||||
throw error(502, 'Could not reach browse service');
|
||||
}
|
||||
};
|
||||
46
ui/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts
Normal file
46
ui/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/chapter-text-preview/[slug]/[n]
|
||||
* Proxies to the scraper's /api/chapter-text-preview endpoint.
|
||||
* Used client-side when the normal chapter path returns no content
|
||||
* (chapter indexed but not yet scraped to MinIO).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
if (!slug || !chapter || chapter < 1) {
|
||||
error(400, 'Invalid slug or chapter number');
|
||||
}
|
||||
|
||||
// Forward optional query params (chapter_url, title) if present
|
||||
const qs = new URLSearchParams();
|
||||
const chapterUrl = url.searchParams.get('chapter_url');
|
||||
const title = url.searchParams.get('title');
|
||||
if (chapterUrl) qs.set('chapter_url', chapterUrl);
|
||||
if (title) qs.set('title', title);
|
||||
|
||||
const scraperRes = await fetch(
|
||||
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}`
|
||||
).catch((e) => {
|
||||
log.error('chapter-preview', 'scraper fetch failed', { slug, chapter, err: String(e) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!scraperRes || !scraperRes.ok) {
|
||||
const status = scraperRes?.status ?? 502;
|
||||
log.error('chapter-preview', 'scraper returned error', { slug, chapter, status });
|
||||
error(status as Parameters<typeof error>[0], 'Chapter preview not available');
|
||||
}
|
||||
|
||||
const data = await scraperRes.json();
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
34
ui/src/routes/api/library/[slug]/+server.ts
Normal file
34
ui/src/routes/api/library/[slug]/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { saveBook, unsaveBook } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/library/[slug]
|
||||
* Save a book to the user's personal library.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
try {
|
||||
await saveBook(locals.sessionId, slug, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('library', 'saveBook failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to save book');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/library/[slug]
|
||||
* Remove a book from the user's personal library.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
try {
|
||||
await unsaveBook(locals.sessionId, slug, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('library', 'unsaveBook failed', { slug, err: String(e) });
|
||||
error(500, 'Failed to remove book');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
32
ui/src/routes/api/presign/audio/+server.ts
Normal file
32
ui/src/routes/api/presign/audio/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { presignAudio } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/presign/audio?slug=...&n=...&voice=...
|
||||
* Returns a presigned MinIO URL for the audio file so the browser
|
||||
* can stream it directly without going through the server.
|
||||
* Returns 404 when the audio has not been generated yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
const n = parseInt(url.searchParams.get('n') ?? '', 10);
|
||||
const voice = url.searchParams.get('voice') ?? undefined;
|
||||
|
||||
if (!slug || !n || n < 1) {
|
||||
error(400, 'Missing slug or n');
|
||||
}
|
||||
|
||||
try {
|
||||
const presignedUrl = await presignAudio(slug, n, voice);
|
||||
return json({ url: presignedUrl });
|
||||
} catch (e) {
|
||||
const status = (e as { status?: number }).status;
|
||||
if (status === 404) {
|
||||
error(404, 'Audio not found');
|
||||
}
|
||||
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
|
||||
error(500, `Could not get presigned URL: ${e}`);
|
||||
}
|
||||
};
|
||||
26
ui/src/routes/api/presign/voice-sample/+server.ts
Normal file
26
ui/src/routes/api/presign/voice-sample/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { presignVoiceSample } from '$lib/server/minio';
|
||||
|
||||
/**
|
||||
* GET /api/presign/voice-sample?voice=af_bella
|
||||
* Returns a presigned URL for the voice sample audio file.
|
||||
* Returns 404 if the sample has not been generated yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const voice = url.searchParams.get('voice');
|
||||
if (!voice) {
|
||||
error(400, 'Missing voice parameter');
|
||||
}
|
||||
|
||||
try {
|
||||
const presignedUrl = await presignVoiceSample(voice);
|
||||
return json({ url: presignedUrl });
|
||||
} catch (e) {
|
||||
const status = (e as { status?: number }).status;
|
||||
if (status === 404) {
|
||||
error(404, 'Voice sample not found');
|
||||
}
|
||||
error(502, `Failed to presign voice sample: ${e}`);
|
||||
}
|
||||
};
|
||||
27
ui/src/routes/api/progress/+server.ts
Normal file
27
ui/src/routes/api/progress/+server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/progress
|
||||
* Body: { slug: string, chapter: number }
|
||||
* Records the user's reading position.
|
||||
* When the user is logged in, progress is keyed by user_id so it syncs across devices.
|
||||
* When anonymous, progress is keyed by the session cookie.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.slug !== 'string' || typeof body.chapter !== 'number') {
|
||||
error(400, 'Invalid body — expected { slug, chapter }');
|
||||
}
|
||||
|
||||
try {
|
||||
await setProgress(locals.sessionId, body.slug, body.chapter, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('progress', 'setProgress failed', { slug: body.slug, chapter: body.chapter, err: String(e) });
|
||||
error(500, 'Failed to save progress');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
56
ui/src/routes/api/progress/audio-time/+server.ts
Normal file
56
ui/src/routes/api/progress/audio-time/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setAudioTime, getAudioTime } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/progress/audio-time?slug=&chapter=
|
||||
* Returns the last saved audio position for a chapter, or null.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
const chapterParam = url.searchParams.get('chapter');
|
||||
|
||||
if (!slug || !chapterParam) {
|
||||
error(400, 'Missing slug or chapter query params');
|
||||
}
|
||||
|
||||
const chapter = parseInt(chapterParam, 10);
|
||||
if (isNaN(chapter)) {
|
||||
error(400, 'chapter must be a number');
|
||||
}
|
||||
|
||||
try {
|
||||
const audioTime = await getAudioTime(locals.sessionId, slug, chapter, locals.user?.id);
|
||||
return json({ audioTime });
|
||||
} catch (e) {
|
||||
log.error('audio-time', 'GET failed', { slug, chapter, err: String(e) });
|
||||
error(500, 'Failed to load audio time');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/progress/audio-time
|
||||
* Body: { slug: string, chapter: number, audioTime: number }
|
||||
* Saves the current audio playback position.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body ||
|
||||
typeof body.slug !== 'string' ||
|
||||
typeof body.chapter !== 'number' ||
|
||||
typeof body.audioTime !== 'number'
|
||||
) {
|
||||
error(400, 'Invalid body — expected { slug, chapter, audioTime }');
|
||||
}
|
||||
|
||||
try {
|
||||
await setAudioTime(locals.sessionId, body.slug, body.chapter, body.audioTime, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('audio-time', 'PATCH failed', { slug: body.slug, chapter: body.chapter, err: String(e) });
|
||||
error(500, 'Failed to save audio time');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
64
ui/src/routes/api/scrape/+server.ts
Normal file
64
ui/src/routes/api/scrape/+server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* POST /api/scrape
|
||||
*
|
||||
* Proxies scrape requests to the Go scraper backend.
|
||||
* Admin-only — returns 403 if the authenticated user is not an admin.
|
||||
*
|
||||
* Request body (JSON):
|
||||
* { "url": "https://novelfire.net/book/..." } — scrape a single book
|
||||
* {} — scrape the full catalogue
|
||||
*
|
||||
* Responses mirror the Go scraper:
|
||||
* 202 Accepted — job enqueued
|
||||
* 409 Conflict — a scrape job is already running
|
||||
* 400 Bad Request
|
||||
* 403 Forbidden — not an admin
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Admin guard
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
let body: { url?: string } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
// empty body is fine — means "scrape all"
|
||||
}
|
||||
|
||||
// Decide which scraper endpoint to call
|
||||
const isBookScrape = typeof body.url === 'string' && body.url.length > 0;
|
||||
const endpoint = isBookScrape ? '/scrape/book' : '/scrape';
|
||||
|
||||
const upstream = `${SCRAPER_URL}${endpoint}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(upstream, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('scrape', 'scraper proxy network error', { endpoint, err: String(e) });
|
||||
throw error(502, 'Could not reach scraper');
|
||||
}
|
||||
|
||||
if (!res.ok && res.status >= 500) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('scrape', 'scraper returned error', { endpoint, status: res.status, body: text });
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
// Pass through the status code from the Go scraper (202, 409, 400, …)
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
62
ui/src/routes/api/scrape/range/+server.ts
Normal file
62
ui/src/routes/api/scrape/range/+server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* POST /api/scrape/range
|
||||
*
|
||||
* Proxies range-scrape requests to the Go scraper backend at POST /scrape/book/range.
|
||||
* Admin-only.
|
||||
*
|
||||
* Request body (JSON):
|
||||
* { "url": "https://novelfire.net/book/...", "from": 50, "to": 100 }
|
||||
* "to" is optional — omit to scrape from "from" to the end.
|
||||
*
|
||||
* Responses mirror the Go scraper:
|
||||
* 202 Accepted — job enqueued
|
||||
* 409 Conflict — a scrape job is already running
|
||||
* 400 Bad Request
|
||||
* 403 Forbidden — not an admin
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// Admin guard
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
let body: { url?: string; from?: number; to?: number } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
throw error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
if (!body.url || typeof body.from !== 'number') {
|
||||
throw error(400, 'url and from are required');
|
||||
}
|
||||
|
||||
const upstream = `${SCRAPER_URL}/scrape/book/range`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(upstream, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: body.url, from: body.from, to: body.to })
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('scrape/range', 'scraper proxy network error', { err: String(e) });
|
||||
throw error(502, 'Could not reach scraper');
|
||||
}
|
||||
|
||||
if (!res.ok && res.status >= 500) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('scrape/range', 'scraper returned error', { status: res.status, body: text });
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
49
ui/src/routes/api/settings/+server.ts
Normal file
49
ui/src/routes/api/settings/+server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSettings, saveSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Returns the current user's settings (auto_next, voice, speed).
|
||||
* Returns defaults if no settings record exists yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
try {
|
||||
const settings = await getSettings(locals.sessionId, locals.user?.id);
|
||||
return json({
|
||||
autoNext: settings?.auto_next ?? false,
|
||||
voice: settings?.voice ?? 'af_bella',
|
||||
speed: settings?.speed ?? 1.0
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
error(500, 'Failed to load settings');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/settings
|
||||
* Body: { autoNext: boolean, voice: string, speed: number }
|
||||
* Saves user preferences.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body ||
|
||||
typeof body.autoNext !== 'boolean' ||
|
||||
typeof body.voice !== 'string' ||
|
||||
typeof body.speed !== 'number'
|
||||
) {
|
||||
error(400, 'Invalid body — expected { autoNext, voice, speed }');
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('settings', 'PUT failed', { err: String(e) });
|
||||
error(500, 'Failed to save settings');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
23
ui/src/routes/api/voices/+server.ts
Normal file
23
ui/src/routes/api/voices/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/voices
|
||||
* Proxies the voice list from the scraper → Kokoro.
|
||||
* Returns { voices: string[] }
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/voices`);
|
||||
if (!res.ok) {
|
||||
return json({ voices: [] });
|
||||
}
|
||||
const data = (await res.json()) as { voices: string[] };
|
||||
return json({ voices: data.voices ?? [] });
|
||||
} catch {
|
||||
return json({ voices: [] });
|
||||
}
|
||||
};
|
||||
57
ui/src/routes/books/+page.server.ts
Normal file
57
ui/src/routes/books/+page.server.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
let allBooks: Awaited<ReturnType<typeof listBooks>>;
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>>;
|
||||
let savedSlugs: Set<string>;
|
||||
|
||||
try {
|
||||
[allBooks, progressList, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load library data', { err: String(e) });
|
||||
allBooks = [];
|
||||
progressList = [];
|
||||
savedSlugs = new Set();
|
||||
}
|
||||
|
||||
// Build a quick lookup: slug → last chapter read
|
||||
const progressMap: Record<string, number> = {};
|
||||
for (const p of progressList) {
|
||||
progressMap[p.slug] = p.chapter;
|
||||
}
|
||||
|
||||
// Library = books the user has started reading OR explicitly saved
|
||||
const progressSlugs = new Set(progressList.map((p) => p.slug));
|
||||
const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug));
|
||||
|
||||
// Sort: books with progress first (most-recently-read order is implicit via progressList),
|
||||
// then saved-only books alphabetically.
|
||||
const withProgress = books.filter((b) => progressSlugs.has(b.slug));
|
||||
const savedOnly = books
|
||||
.filter((b) => !progressSlugs.has(b.slug))
|
||||
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
|
||||
|
||||
// Re-sort withProgress by most recent progress update
|
||||
const progressUpdatedMap: Record<string, string> = {};
|
||||
for (const p of progressList) {
|
||||
progressUpdatedMap[p.slug] = p.updated;
|
||||
}
|
||||
withProgress.sort((a, b) => {
|
||||
const ta = progressUpdatedMap[a.slug] ?? '';
|
||||
const tb = progressUpdatedMap[b.slug] ?? '';
|
||||
return tb.localeCompare(ta); // descending — most recently read first
|
||||
});
|
||||
|
||||
return {
|
||||
books: [...withProgress, ...savedOnly],
|
||||
progressMap,
|
||||
savedSlugs: [...savedSlugs]
|
||||
};
|
||||
};
|
||||
99
ui/src/routes/books/+page.svelte
Normal file
99
ui/src/routes/books/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
const parsed = JSON.parse(genres);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Library — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Library</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !data.books?.length}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">Your library is empty.</p>
|
||||
<p class="text-sm mt-2">
|
||||
Books you start reading or save from
|
||||
<a href="/browse" class="text-amber-400 hover:text-amber-300 transition-colors">Discover</a>
|
||||
will appear here.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each data.books as book}
|
||||
{@const lastChapter = data.progressMap[book.slug]}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
>
|
||||
<!-- Cover image -->
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">
|
||||
{book.title ?? ''}
|
||||
</h2>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author ?? ''}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-auto pt-1 flex items-center justify-between gap-1">
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 truncate max-w-[60%]">
|
||||
{book.status}
|
||||
</span>
|
||||
{/if}
|
||||
{#if lastChapter}
|
||||
<span class="text-xs text-amber-400 font-medium ml-auto whitespace-nowrap">
|
||||
ch.{lastChapter}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
103
ui/src/routes/books/[slug]/+page.server.ts
Normal file
103
ui/src/routes/books/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
// Minimal chapter shape returned by /api/book-preview
|
||||
export interface PreviewChapter {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
// Try fetching from PocketBase first
|
||||
let book = await getBook(slug).catch((e) => {
|
||||
log.error('books', 'getBook failed', { slug, err: String(e) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (book) {
|
||||
// Book is in the library — normal path
|
||||
let chapters, progress, saved;
|
||||
try {
|
||||
[chapters, progress, saved] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load book page data', { slug, err: String(e) });
|
||||
throw error(500, 'Failed to load book');
|
||||
}
|
||||
|
||||
return {
|
||||
book,
|
||||
chapters,
|
||||
previewChapters: null as PreviewChapter[] | null,
|
||||
inLib: true,
|
||||
saved,
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
};
|
||||
}
|
||||
|
||||
// Book not in PocketBase — try live preview from scraper
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
log.warn('books', 'book-preview returned error', { slug, status: res.status });
|
||||
error(404, `Book "${slug}" not found`);
|
||||
}
|
||||
const preview: {
|
||||
in_lib: boolean;
|
||||
meta: {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
total_chapters: number;
|
||||
source_url: string;
|
||||
};
|
||||
chapters: PreviewChapter[];
|
||||
} = await res.json();
|
||||
|
||||
// Shape the meta into a Book-like object (no PocketBase id fields)
|
||||
const previewBook = {
|
||||
id: '',
|
||||
slug: preview.meta.slug || slug,
|
||||
title: preview.meta.title,
|
||||
author: preview.meta.author,
|
||||
cover: preview.meta.cover,
|
||||
status: preview.meta.status,
|
||||
genres: preview.meta.genres ?? [],
|
||||
summary: preview.meta.summary,
|
||||
total_chapters: preview.meta.total_chapters,
|
||||
source_url: preview.meta.source_url,
|
||||
ranking: 0,
|
||||
meta_updated: ''
|
||||
};
|
||||
|
||||
return {
|
||||
book: previewBook,
|
||||
chapters: [],
|
||||
previewChapters: preview.chapters,
|
||||
inLib: preview.in_lib,
|
||||
saved: false,
|
||||
lastChapter: null,
|
||||
isAdmin: locals.user?.role === 'admin'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('books', 'book-preview fetch failed', { slug, err: String(e) });
|
||||
error(404, `Book "${slug}" not found`);
|
||||
}
|
||||
};
|
||||
414
ui/src/routes/books/[slug]/+page.svelte
Normal file
414
ui/src/routes/books/[slug]/+page.svelte
Normal file
@@ -0,0 +1,414 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Save / unsave ─────────────────────────────────────────────────────────
|
||||
let saved = $state(data.saved);
|
||||
let saving = $state(false);
|
||||
|
||||
async function toggleSave() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
const method = saved ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/library/${encodeURIComponent(data.book.slug)}`, { method });
|
||||
if (res.ok) saved = !saved;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGenres(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try {
|
||||
return JSON.parse(genres);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const genres = $derived(parseGenres(data.book.genres));
|
||||
|
||||
// Paginate chapter list — show 100 at a time
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// Start on the page that contains the current chapter (if any)
|
||||
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
|
||||
if (!chapterNum || list.length === 0) return 0;
|
||||
const idx = list.findIndex((c) => c.number === chapterNum);
|
||||
if (idx === -1) return 0;
|
||||
return Math.floor(idx / PAGE_SIZE);
|
||||
}
|
||||
|
||||
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
|
||||
|
||||
// Use preview chapters if the book is not in the library
|
||||
const chapterList = $derived(
|
||||
data.inLib
|
||||
? data.chapters
|
||||
: (data.previewChapters ?? [])
|
||||
);
|
||||
const totalPages = $derived(Math.ceil(chapterList.length / PAGE_SIZE));
|
||||
const visibleChapters = $derived(
|
||||
chapterList.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
);
|
||||
|
||||
// ── Chapter list polling ──────────────────────────────────────────────────
|
||||
// When the book was just added to the library via preview (inLib=true but
|
||||
// no chapters yet), poll until the background WriteChapterRefs completes.
|
||||
let pollingChapters = $state(data.inLib && data.chapters.length === 0);
|
||||
|
||||
onMount(() => {
|
||||
if (!pollingChapters) return;
|
||||
let attempts = 0;
|
||||
const MAX_ATTEMPTS = 20; // ~10 seconds
|
||||
const timer = setInterval(async () => {
|
||||
attempts++;
|
||||
await invalidateAll();
|
||||
if (data.chapters.length > 0 || attempts >= MAX_ATTEMPTS) {
|
||||
pollingChapters = false;
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
let scraping = $state(false);
|
||||
let scrapeResult = $state<'queued' | 'busy' | 'error' | ''>('');
|
||||
|
||||
async function rescrape() {
|
||||
if (scraping || !data.book.source_url) return;
|
||||
scraping = true;
|
||||
scrapeResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: data.book.source_url })
|
||||
});
|
||||
if (res.ok) scrapeResult = 'queued';
|
||||
else if (res.status === 409) scrapeResult = 'busy';
|
||||
else scrapeResult = 'error';
|
||||
} catch {
|
||||
scrapeResult = 'error';
|
||||
} finally {
|
||||
scraping = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: scrape range ───────────────────────────────────────────────────
|
||||
let rangeFrom = $state('');
|
||||
let rangeTo = $state('');
|
||||
let rangeScraping = $state(false);
|
||||
let rangeResult = $state<'queued' | 'busy' | 'error' | ''>('');
|
||||
|
||||
async function scrapeRange() {
|
||||
if (rangeScraping || !data.book.source_url) return;
|
||||
const from = parseInt(rangeFrom, 10);
|
||||
const to = parseInt(rangeTo, 10);
|
||||
if (!from || from < 1) return;
|
||||
rangeScraping = true;
|
||||
rangeResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/scrape/range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: data.book.source_url, from, to: to || undefined })
|
||||
});
|
||||
if (res.ok) rangeResult = 'queued';
|
||||
else if (res.status === 409) rangeResult = 'busy';
|
||||
else rangeResult = 'error';
|
||||
} catch {
|
||||
rangeResult = 'error';
|
||||
} finally {
|
||||
rangeScraping = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeFromChapter(n: number) {
|
||||
if (rangeScraping || !data.book.source_url) return;
|
||||
rangeScraping = true;
|
||||
rangeResult = '';
|
||||
try {
|
||||
const res = await fetch('/api/scrape/range', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: data.book.source_url, from: n })
|
||||
});
|
||||
if (res.ok) rangeResult = 'queued';
|
||||
else if (res.status === 409) rangeResult = 'busy';
|
||||
else rangeResult = 'error';
|
||||
} catch {
|
||||
rangeResult = 'error';
|
||||
} finally {
|
||||
rangeScraping = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Book header -->
|
||||
<div class="flex gap-6 mb-8">
|
||||
{#if data.book.cover}
|
||||
<img
|
||||
src={data.book.cover}
|
||||
alt={data.book.title}
|
||||
class="w-32 sm:w-40 rounded-lg object-cover flex-shrink-0 border border-zinc-700"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
|
||||
{#if !data.inLib}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0" title="This book was fetched live from the source and is not yet in your library">
|
||||
not in library
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.book.author}
|
||||
<p class="text-zinc-400 text-sm">{data.book.author}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
{#if data.book.status}
|
||||
<span class="text-xs px-2 py-1 rounded bg-zinc-700 text-zinc-300">{data.book.status}</span>
|
||||
{/if}
|
||||
{#each genres as genre}
|
||||
<span class="text-xs px-2 py-1 rounded bg-zinc-800 text-zinc-400">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.book.summary}
|
||||
<p class="text-zinc-400 text-sm leading-relaxed line-clamp-4 mt-1">{data.book.summary}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 mt-2 flex-wrap items-center">
|
||||
{#if data.lastChapter}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
|
||||
class="px-4 py-2 bg-amber-400 text-zinc-900 font-semibold rounded text-sm hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/1"
|
||||
class="px-4 py-2 bg-zinc-700 text-zinc-100 font-semibold rounded text-sm hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Save / unsave button -->
|
||||
{#if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50
|
||||
{saved
|
||||
? 'bg-amber-400/20 text-amber-300 hover:bg-red-500/20 hover:text-red-300 border border-amber-400/30 hover:border-red-400/30'
|
||||
: 'bg-zinc-700 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-600 border border-zinc-600'}"
|
||||
>
|
||||
{#if saving}
|
||||
<svg class="w-4 h-4 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>
|
||||
{:else if saved}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<h2 class="text-lg font-semibold text-zinc-100">
|
||||
Chapters
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
{#if data.isAdmin && data.book.source_url}
|
||||
<button
|
||||
onclick={rescrape}
|
||||
disabled={scraping}
|
||||
class="px-3 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1.5
|
||||
{scraping
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
|
||||
title="Re-scrape this book from source"
|
||||
>
|
||||
{#if scraping}
|
||||
<svg class="w-3 h-3 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>
|
||||
Queuing…
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Rescrape
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex gap-2 items-center text-sm">
|
||||
<button
|
||||
onclick={() => (page = Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span class="text-zinc-400">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page === totalPages - 1}
|
||||
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if scrapeResult}
|
||||
<div class="mb-3 px-3 py-2 rounded text-xs font-medium
|
||||
{scrapeResult === 'queued' ? 'bg-green-900/40 text-green-300 border border-green-800' :
|
||||
scrapeResult === 'busy' ? 'bg-amber-900/40 text-amber-300 border border-amber-800' :
|
||||
'bg-red-900/40 text-red-300 border border-red-800'}">
|
||||
{scrapeResult === 'queued' ? 'Rescrape queued — running in background.' :
|
||||
scrapeResult === 'busy' ? 'Scraper is busy with another job. Try again shortly.' :
|
||||
'Failed to queue rescrape. Check server logs.'}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Admin: range scrape controls -->
|
||||
{#if data.isAdmin && data.book.source_url}
|
||||
<div class="mb-4 p-3 rounded bg-zinc-800/60 border border-zinc-700 flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
|
||||
<input
|
||||
id="range-from"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeFrom}
|
||||
placeholder="1"
|
||||
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
|
||||
<input
|
||||
id="range-to"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeTo}
|
||||
placeholder="end"
|
||||
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={scrapeRange}
|
||||
disabled={rangeScraping || !rangeFrom}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{rangeScraping || !rangeFrom
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
|
||||
>
|
||||
{rangeScraping ? 'Queuing…' : 'Scrape range'}
|
||||
</button>
|
||||
|
||||
{#if rangeResult}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
|
||||
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pollingChapters}
|
||||
<!-- Chapter list is being indexed in the background -->
|
||||
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
|
||||
<svg class="w-4 h-4 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>
|
||||
Indexing chapter list…
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 opacity-40 pointer-events-none">
|
||||
{#each Array(8) as _}
|
||||
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if chapterList.length === 0}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||
{#each visibleChapters as chapter}
|
||||
{@const isCurrent = data.lastChapter === chapter.number}
|
||||
{@const chapterUrl = data.inLib
|
||||
? `/books/${data.book.slug}/chapters/${chapter.number}`
|
||||
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
|
||||
<div class="flex items-center gap-3 px-3 py-2 rounded hover:bg-zinc-800 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
|
||||
<a
|
||||
href={chapterUrl}
|
||||
class="flex items-center gap-3 flex-1 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-mono w-10 text-right flex-shrink-0 {isCurrent
|
||||
? 'text-amber-400'
|
||||
: 'text-zinc-500'}"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
<span class="text-sm text-zinc-300 group-hover:text-zinc-100 truncate flex-1">
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
</span>
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-amber-400 flex-shrink-0">reading</span>
|
||||
{/if}
|
||||
{#if (chapter as { date_label?: string }).date_label}
|
||||
<span class="text-xs text-zinc-600 flex-shrink-0 hidden sm:block">{(chapter as { date_label?: string }).date_label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
<!-- Admin: scrape from this chapter up -->
|
||||
{#if data.isAdmin && data.book.source_url && data.inLib}
|
||||
<button
|
||||
onclick={() => scrapeFromChapter(chapter.number)}
|
||||
disabled={rangeScraping}
|
||||
class="opacity-0 group-hover:opacity-100 shrink-0 text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-500 hover:bg-amber-500/30 transition-all border border-amber-500/20 disabled:opacity-30"
|
||||
title="Scrape from chapter {chapter.number} up"
|
||||
>
|
||||
↑ here
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
137
ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts
Normal file
137
ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
||||
import { presignChapter } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const n = parseInt(params.n, 10);
|
||||
|
||||
if (!n || n < 1) error(400, 'Invalid chapter number');
|
||||
|
||||
const isPreview = url.searchParams.get('preview') === '1';
|
||||
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
|
||||
const chapterTitle = url.searchParams.get('title') ?? '';
|
||||
|
||||
if (isPreview) {
|
||||
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
|
||||
const previewParams = new URLSearchParams();
|
||||
if (chapterUrl) previewParams.set('chapter_url', chapterUrl);
|
||||
if (chapterTitle) previewParams.set('title', chapterTitle);
|
||||
|
||||
let chapterData: { slug: string; number: number; title: string; text: string; url: string };
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
log.error('chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
|
||||
error(404, `Chapter ${n} not found`);
|
||||
}
|
||||
chapterData = await res.json();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) });
|
||||
error(502, 'Could not fetch chapter preview');
|
||||
}
|
||||
|
||||
// Wrap plain text in minimal HTML paragraphs for display
|
||||
const html = chapterData.text
|
||||
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
||||
: '';
|
||||
|
||||
// Fetch voices (non-critical for preview)
|
||||
let voices: string[] = [];
|
||||
try {
|
||||
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
|
||||
if (vRes.ok) {
|
||||
const d = (await vRes.json()) as { voices: string[] };
|
||||
voices = d.voices ?? [];
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
// Try to get book title/cover from PocketBase for breadcrumbs; fall back to slug
|
||||
const pb = await getBook(slug).catch(() => null);
|
||||
|
||||
return {
|
||||
book: {
|
||||
slug,
|
||||
title: pb?.title ?? slug,
|
||||
cover: pb?.cover ?? ''
|
||||
},
|
||||
chapter: {
|
||||
id: '',
|
||||
slug,
|
||||
number: n,
|
||||
title: chapterData.title || `Chapter ${n}`,
|
||||
date_label: ''
|
||||
},
|
||||
html,
|
||||
voices,
|
||||
prev: null as number | null,
|
||||
next: null as number | null,
|
||||
chapters: [] as { number: number; title: string }[],
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: true
|
||||
};
|
||||
}
|
||||
|
||||
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
|
||||
// Fetch book metadata, chapter index, and voice list in parallel
|
||||
const [book, chapters, voicesRes] = await Promise.all([
|
||||
getBook(slug),
|
||||
listChapterIdx(slug),
|
||||
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
|
||||
]);
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
|
||||
const chapterIdx = chapters.find((c) => c.number === n);
|
||||
if (!chapterIdx) error(404, `Chapter ${n} not found`);
|
||||
|
||||
// Parse voices — fall back to a minimal default list on error
|
||||
let voices: string[] = [];
|
||||
try {
|
||||
if (voicesRes?.ok) {
|
||||
const data = (await voicesRes.json()) as { voices: string[] };
|
||||
voices = data.voices ?? [];
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — UI will use store default
|
||||
}
|
||||
|
||||
// Get presigned URL and fetch chapter markdown server-side
|
||||
let html = '';
|
||||
try {
|
||||
const presignUrl = await presignChapter(slug, n);
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
} catch (e) {
|
||||
// Don't hard-fail — show empty content with error message
|
||||
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
}
|
||||
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false
|
||||
};
|
||||
};
|
||||
154
ui/src/routes/books/[slug]/chapters/[n]/+page.svelte
Normal file
154
ui/src/routes/books/[slug]/chapters/[n]/+page.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { marked } from 'marked';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Live-fetch fallback when chapter text is missing in storage ───────────
|
||||
let html = $state(data.html);
|
||||
let fetchingContent = $state(!data.isPreview && !data.html);
|
||||
let fetchError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
// Record reading progress (skip for preview chapters)
|
||||
if (!data.isPreview) {
|
||||
try {
|
||||
await fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
// If the normal path returned no content, fall back to live preview scrape
|
||||
if (!data.isPreview && !data.html) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = 'Chapter content not available.';
|
||||
}
|
||||
} catch (e) {
|
||||
fetchError = 'Could not fetch chapter content.';
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="text-zinc-400 hover:text-zinc-100 text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{data.book.title}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
← Ch.{data.prev}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Ch.{data.next} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-6">
|
||||
<p class="text-zinc-500 text-sm mb-1">Chapter {data.chapter.number}</p>
|
||||
<h1 class="text-xl font-bold text-zinc-100">
|
||||
{data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
</h1>
|
||||
{#if data.chapter.date_label}
|
||||
<p class="text-zinc-600 text-xs mt-1">{data.chapter.date_label}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Audio player -->
|
||||
{#if !data.isPreview}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
/>
|
||||
{:else}
|
||||
<div class="mb-6 px-4 py-3 rounded bg-zinc-800/60 border border-zinc-700 text-zinc-500 text-sm">
|
||||
Preview chapter — audio not available for books outside the library.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter content -->
|
||||
{#if fetchingContent}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-zinc-500 text-sm">
|
||||
<svg class="w-6 h-6 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>
|
||||
Fetching chapter…
|
||||
</div>
|
||||
{:else if !html}
|
||||
<div class="text-zinc-500 text-center py-16">
|
||||
<p>{fetchError || 'Chapter content not available.'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose-chapter mt-8">
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom nav -->
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800 gap-4">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
← Previous chapter
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Next chapter →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
170
ui/src/routes/browse/+page.server.ts
Normal file
170
ui/src/routes/browse/+page.server.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export interface NovelListing {
|
||||
slug: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rank: string;
|
||||
rating: string;
|
||||
chapters: string;
|
||||
url: string;
|
||||
// enriched fields (only set when sort=rank)
|
||||
author?: string;
|
||||
status?: string;
|
||||
genres?: string[];
|
||||
source_url?: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const page = url.searchParams.get('page') ?? '1';
|
||||
const genre = url.searchParams.get('genre') ?? 'all';
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
|
||||
let novels: NovelListing[] = [];
|
||||
let pageNum = parseInt(page, 10) || 1;
|
||||
let hasNext = false;
|
||||
let searchQuery = '';
|
||||
let searchLocalCount = 0;
|
||||
let searchRemoteCount = 0;
|
||||
|
||||
// ── Search mode: ?q= overrides browse/ranking ─────────────────────────
|
||||
if (q.trim().length >= 2) {
|
||||
searchQuery = q.trim();
|
||||
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(searchQuery)}`;
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('browse', 'search returned error', { status: res.status });
|
||||
throw error(502, `Search failed: ${res.status}`);
|
||||
}
|
||||
const data: {
|
||||
results: NovelListing[];
|
||||
local_count: number;
|
||||
remote_count: number;
|
||||
} = await res.json();
|
||||
novels = data.results ?? [];
|
||||
searchLocalCount = data.local_count ?? 0;
|
||||
searchRemoteCount = data.remote_count ?? 0;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('browse', 'search network error', { q: searchQuery, err: String(e) });
|
||||
throw error(502, 'Could not reach search service');
|
||||
}
|
||||
|
||||
return {
|
||||
novels,
|
||||
page: 1,
|
||||
hasNext: false,
|
||||
genre,
|
||||
sort,
|
||||
status,
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
searchQuery,
|
||||
searchLocalCount,
|
||||
searchRemoteCount
|
||||
};
|
||||
}
|
||||
|
||||
if (sort === 'rank') {
|
||||
// Ranking view: fetch from /api/ranking which returns richer metadata.
|
||||
// Pagination and filters (genre/status) don't apply here — the ranking
|
||||
// is a single pre-computed list from the last catalogue scrape.
|
||||
const apiURL = `${SCRAPER_URL}/api/ranking`;
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('browse', 'scraper ranking returned error', { status: res.status });
|
||||
throw error(502, `Ranking fetch failed: ${res.status}`);
|
||||
}
|
||||
const items: Array<{
|
||||
rank: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
source_url: string;
|
||||
}> = await res.json();
|
||||
novels = (items ?? []).map((item) => ({
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
cover: item.cover,
|
||||
rank: item.rank != null ? `#${item.rank}` : '',
|
||||
rating: '',
|
||||
chapters: '',
|
||||
url: item.source_url ?? '',
|
||||
author: item.author,
|
||||
status: item.status,
|
||||
genres: item.genres ?? [],
|
||||
source_url: item.source_url
|
||||
}));
|
||||
pageNum = 1;
|
||||
hasNext = false;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('browse', 'scraper ranking network error', { err: String(e) });
|
||||
throw error(502, 'Could not load ranking');
|
||||
}
|
||||
} else {
|
||||
// Browse view: paginated catalogue from /api/browse.
|
||||
const params = new URLSearchParams({ page, genre, sort, status });
|
||||
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL });
|
||||
throw error(502, `Browse fetch failed: ${res.status}`);
|
||||
}
|
||||
const data: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
|
||||
novels = data.novels ?? [];
|
||||
pageNum = data.page ?? 1;
|
||||
hasNext = data.hasNext ?? false;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) });
|
||||
throw error(502, 'Could not load browse page');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
novels,
|
||||
page: pageNum,
|
||||
hasNext,
|
||||
genre,
|
||||
sort,
|
||||
status,
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
searchQuery: '',
|
||||
searchLocalCount: 0,
|
||||
searchRemoteCount: 0
|
||||
};
|
||||
};
|
||||
|
||||
// Admin action: trigger a full catalogue scrape (refreshes ranking + library).
|
||||
export const actions: Actions = {
|
||||
refresh: async ({ locals, fetch }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
if (res.status === 409) return { status: 'busy' };
|
||||
if (!res.ok) return { status: 'error' };
|
||||
return { status: 'queued' };
|
||||
} catch {
|
||||
return { status: 'error' };
|
||||
}
|
||||
}
|
||||
};
|
||||
699
ui/src/routes/browse/+page.svelte
Normal file
699
ui/src/routes/browse/+page.svelte
Normal file
@@ -0,0 +1,699 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { navigating } from '$app/state';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { NovelListing } from './+page.server';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// Track which novel card is currently being navigated to
|
||||
let loadingSlug = $state<string | null>(null);
|
||||
|
||||
// Clear loading state when navigation ends (success or failure)
|
||||
$effect(() => {
|
||||
if (!navigating) loadingSlug = null;
|
||||
});
|
||||
|
||||
function handleNovelClick(slug: string) {
|
||||
loadingSlug = slug;
|
||||
}
|
||||
|
||||
// ── Infinite scroll state ────────────────────────────────────────────────
|
||||
// novels is the accumulated list across all fetched pages.
|
||||
// Seeded from SSR page 1; new pages are appended client-side.
|
||||
let novels = $state<NovelListing[]>(data.novels);
|
||||
let currentPage = $state(data.page);
|
||||
let hasNext = $state(data.hasNext);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
// A key derived from the active filters — when it changes, reset the list
|
||||
// to the fresh SSR data (SvelteKit already re-ran the server load).
|
||||
let filterKey = $derived(`${data.sort}|${data.genre}|${data.status}|${data.searchQuery}`);
|
||||
let lastFilterKey = '';
|
||||
$effect(() => {
|
||||
if (filterKey !== lastFilterKey) {
|
||||
lastFilterKey = filterKey;
|
||||
novels = data.novels;
|
||||
currentPage = data.page;
|
||||
hasNext = data.hasNext;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadNextPage() {
|
||||
if (loadingMore || !hasNext) return;
|
||||
// Infinite scroll only applies in browse mode (not rank, not search)
|
||||
if (data.sort === 'rank' || data.searchQuery) return;
|
||||
|
||||
loadingMore = true;
|
||||
const nextPage = currentPage + 1;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(nextPage),
|
||||
genre: data.genre,
|
||||
sort: data.sort,
|
||||
status: data.status
|
||||
});
|
||||
const res = await fetch(`/api/browse-page?${params.toString()}`);
|
||||
if (!res.ok) return;
|
||||
const body: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
|
||||
novels = [...novels, ...(body.novels ?? [])];
|
||||
currentPage = body.page ?? nextPage;
|
||||
hasNext = body.hasNext ?? false;
|
||||
} catch {
|
||||
// silently ignore — user can scroll again to retry
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IntersectionObserver sentinel ────────────────────────────────────────
|
||||
let sentinel = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadNextPage();
|
||||
},
|
||||
{ rootMargin: '300px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Filter options
|
||||
const genres = [
|
||||
{ value: 'all', label: 'All Genres' },
|
||||
{ value: 'action', label: 'Action' },
|
||||
{ value: 'adventure', label: 'Adventure' },
|
||||
{ value: 'comedy', label: 'Comedy' },
|
||||
{ value: 'drama', label: 'Drama' },
|
||||
{ value: 'fantasy', label: 'Fantasy' },
|
||||
{ value: 'harem', label: 'Harem' },
|
||||
{ value: 'historical', label: 'Historical' },
|
||||
{ value: 'horror', label: 'Horror' },
|
||||
{ value: 'isekai', label: 'Isekai' },
|
||||
{ value: 'martial-arts', label: 'Martial Arts' },
|
||||
{ value: 'mystery', label: 'Mystery' },
|
||||
{ value: 'psychological', label: 'Psychological' },
|
||||
{ value: 'romance', label: 'Romance' },
|
||||
{ value: 'sci-fi', label: 'Sci-Fi' },
|
||||
{ value: 'system', label: 'System' },
|
||||
{ value: 'xianxia', label: 'Xianxia' }
|
||||
];
|
||||
const sorts = [
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'update', label: 'Updated' },
|
||||
{ value: 'rank', label: 'Ranking' }
|
||||
];
|
||||
const statuses = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'ongoing', label: 'Ongoing' },
|
||||
{ value: 'completed', label: 'Completed' }
|
||||
];
|
||||
|
||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||
// don't apply to that endpoint.
|
||||
const isRankView = $derived(data.sort === 'rank');
|
||||
const isSearchView = $derived(!!data.searchQuery);
|
||||
|
||||
|
||||
// View toggle: 'grid' | 'list'. Persisted in localStorage.
|
||||
// Rank view always uses list; otherwise restore saved preference (default: grid).
|
||||
const VIEW_KEY = 'libnovel:browse:view';
|
||||
function savedView(): 'grid' | 'list' {
|
||||
if (data.sort === 'rank') return 'list';
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const v = localStorage.getItem(VIEW_KEY);
|
||||
if (v === 'grid' || v === 'list') return v;
|
||||
}
|
||||
return 'grid';
|
||||
}
|
||||
let view = $state<'grid' | 'list'>(savedView());
|
||||
// Keep view in sync when sort changes via filter form, and persist changes.
|
||||
$effect(() => {
|
||||
if (data.sort === 'rank' && view === 'grid') view = 'list';
|
||||
});
|
||||
$effect(() => {
|
||||
if (typeof localStorage !== 'undefined' && data.sort !== 'rank') {
|
||||
localStorage.setItem(VIEW_KEY, view);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: per-novel scrape state (grid view)
|
||||
let scraping: Record<string, boolean> = $state({});
|
||||
let scrapeResult: Record<string, string> = $state({});
|
||||
|
||||
async function scrapeNovel(novel: NovelListing) {
|
||||
scraping[novel.slug] = true;
|
||||
scrapeResult[novel.slug] = '';
|
||||
try {
|
||||
const res = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: novel.url })
|
||||
});
|
||||
if (res.ok) scrapeResult[novel.slug] = 'queued';
|
||||
else if (res.status === 409) scrapeResult[novel.slug] = 'busy';
|
||||
else if (res.status === 403) scrapeResult[novel.slug] = 'forbidden';
|
||||
else scrapeResult[novel.slug] = 'error';
|
||||
} catch {
|
||||
scrapeResult[novel.slug] = 'error';
|
||||
} finally {
|
||||
scraping[novel.slug] = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin: refresh catalogue
|
||||
let refreshing = $state(false);
|
||||
|
||||
// ── Collapsible filters panel ────────────────────────────────────────────
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
// Human-readable summary of active filters shown on the toggle button
|
||||
const filterSummary = $derived(() => {
|
||||
const parts: string[] = [];
|
||||
const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort;
|
||||
parts.push(sortLabel);
|
||||
if (data.genre && data.genre !== 'all') {
|
||||
const genreLabel = genres.find((g) => g.value === data.genre)?.label ?? data.genre;
|
||||
parts.push(genreLabel);
|
||||
}
|
||||
if (data.status && data.status !== 'all') {
|
||||
const statusLabel = statuses.find((s) => s.value === data.status)?.label ?? data.status;
|
||||
parts.push(statusLabel);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
});
|
||||
|
||||
// Whether any non-default filter is active (used to show a dot indicator)
|
||||
const hasActiveFilters = $derived(
|
||||
(data.genre && data.genre !== 'all') ||
|
||||
(data.status && data.status !== 'all') ||
|
||||
(data.sort && data.sort !== 'popular')
|
||||
);
|
||||
|
||||
// ── Scroll-to-top button ─────────────────────────────────────────────────
|
||||
let showScrollTop = $state(false);
|
||||
$effect(() => {
|
||||
function onScroll() {
|
||||
showScrollTop = window.scrollY > 400;
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Discover — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{#if isSearchView}
|
||||
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||
{/if}
|
||||
{:else if isRankView}
|
||||
{#if novels.length > 0}
|
||||
{novels.length} novels ranked from last catalogue scrape
|
||||
{:else}
|
||||
No ranking data — run a full catalogue scrape to populate
|
||||
{/if}
|
||||
{:else}
|
||||
Browse novels from novelfire.net
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Admin flash messages -->
|
||||
{#if form}
|
||||
{#if form.status === 'queued'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
|
||||
Full catalogue scrape queued. Library and ranking will update as books are processed.
|
||||
</div>
|
||||
{:else if form.status === 'busy'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
|
||||
A scrape job is already running. Check back once it finishes.
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-red-900/40 border border-red-700 text-red-300 text-sm">
|
||||
Failed to queue scrape. Check that the scraper service is reachable.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar: search + filter toggle + view toggle + admin refresh -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<!-- Search (grows to fill available space) -->
|
||||
<form method="GET" action="/browse" class="flex flex-1 gap-2 min-w-0">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchQuery}
|
||||
placeholder="Search…"
|
||||
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 placeholder-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
{#if data.searchQuery}
|
||||
<a
|
||||
href="/browse"
|
||||
class="px-3 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Clear
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Filters toggle button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (filtersOpen = !filtersOpen)}
|
||||
aria-expanded={filtersOpen}
|
||||
class="relative flex items-center gap-1.5 px-3 py-2 rounded border text-sm font-medium transition-colors whitespace-nowrap
|
||||
{filtersOpen
|
||||
? 'bg-zinc-700 border-zinc-500 text-zinc-100'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100'}"
|
||||
>
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 4h18M7 8h10M11 12h2M9 16h6" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Filters</span>
|
||||
<!-- Active indicator dot -->
|
||||
{#if hasActiveFilters}
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center bg-zinc-800 border border-zinc-700 rounded overflow-hidden shrink-0">
|
||||
<button
|
||||
onclick={() => (view = 'grid')}
|
||||
title="Grid view"
|
||||
class="px-2.5 py-2 transition-colors {view === 'grid'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (view = 'list')}
|
||||
title="List view"
|
||||
class="px-2.5 py-2 transition-colors {view === 'list'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Admin: refresh catalogue -->
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/refresh"
|
||||
use:enhance={() => {
|
||||
refreshing = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
refreshing = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="hidden sm:block px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Active filter summary (shown when panel is closed and filters are active) -->
|
||||
{#if !filtersOpen && hasActiveFilters}
|
||||
<p class="text-xs text-zinc-500 mb-3">
|
||||
<span class="text-zinc-400">{filterSummary()}</span>
|
||||
<a href="/browse" class="ml-2 text-zinc-600 hover:text-zinc-400 underline underline-offset-2">clear</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Collapsible filter panel -->
|
||||
{#if filtersOpen}
|
||||
<!-- Admin refresh (mobile only — outside filter form to avoid nested <form>) -->
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/refresh"
|
||||
use:enhance={() => {
|
||||
refreshing = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
refreshing = false;
|
||||
};
|
||||
}}
|
||||
class="sm:hidden mb-2"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="w-full px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<form method="GET" action="/browse" class="mb-4 p-3 rounded-lg bg-zinc-800/60 border border-zinc-700 flex flex-col gap-3">
|
||||
<input type="hidden" name="page" value="1" />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-sort" class="text-xs text-zinc-500 uppercase tracking-wide">Sort</label>
|
||||
<select
|
||||
id="filter-sort"
|
||||
name="sort"
|
||||
value={data.sort}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
|
||||
>
|
||||
{#each sorts as s}
|
||||
<option value={s.value}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-genre" class="text-xs text-zinc-500 uppercase tracking-wide">Genre</label>
|
||||
<select
|
||||
id="filter-genre"
|
||||
name="genre"
|
||||
value={data.genre}
|
||||
disabled={isRankView}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
>
|
||||
{#each genres as g}
|
||||
<option value={g.value}>{g.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-status" class="text-xs text-zinc-500 uppercase tracking-wide">Status</label>
|
||||
<select
|
||||
id="filter-status"
|
||||
name="status"
|
||||
value={data.status}
|
||||
disabled={isRankView}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
>
|
||||
{#each statuses as st}
|
||||
<option value={st.value}>{st.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isRankView}
|
||||
<p class="text-xs text-zinc-500 italic">Genre & status filters apply to Browse only</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<a href="/browse" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
|
||||
Reset
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
onclick={() => (filtersOpen = false)}
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
{#if novels.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if isSearchView}
|
||||
Try a different search term.
|
||||
{:else if isRankView}
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
||||
{:else}
|
||||
Ask an admin to run a catalogue scrape.
|
||||
{/if}
|
||||
{:else}
|
||||
Try different filters or check back later.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{:else if view === 'grid'}
|
||||
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each novels as novel}
|
||||
{@const isLoading = loadingSlug === novel.slug}
|
||||
<a
|
||||
href="/books/{novel.slug}"
|
||||
onclick={() => handleNovelClick(novel.slug)}
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 border transition-colors relative
|
||||
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
{#if novel.cover}
|
||||
<img
|
||||
src={novel.cover}
|
||||
alt={novel.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if novel.rank}
|
||||
<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-amber-400 font-bold">
|
||||
{novel.rank}
|
||||
</span>
|
||||
{/if}
|
||||
{#if novel.rating}
|
||||
<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-300">
|
||||
{novel.rating}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Loading overlay -->
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 animate-spin text-amber-400" 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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{novel.title}</h2>
|
||||
{#if novel.author}
|
||||
<p class="text-xs text-zinc-500 truncate">{novel.author}</p>
|
||||
{:else if novel.chapters}
|
||||
<p class="text-xs text-zinc-500 truncate">{novel.chapters}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Admin: per-novel scrape button -->
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="mt-auto pt-1">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">Scraper busy</span>
|
||||
{:else if scrapeResult[novel.slug] === 'forbidden'}
|
||||
<span class="text-xs text-red-400 font-medium">Forbidden</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-red-400 font-medium">Error</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── List view ─────────────────────────────────────────────────────── -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each novels as novel}
|
||||
{@const isLoading = loadingSlug === novel.slug}
|
||||
<div
|
||||
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
|
||||
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
|
||||
>
|
||||
<!-- Rank / index -->
|
||||
{#if novel.rank}
|
||||
<span class="text-amber-400 font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-zinc-900 relative">
|
||||
{#if novel.cover}
|
||||
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 animate-spin text-amber-400" 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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if novel.slug}
|
||||
<a
|
||||
href="/books/{novel.slug}"
|
||||
onclick={() => handleNovelClick(novel.slug)}
|
||||
class="text-sm font-semibold transition-colors line-clamp-1
|
||||
{isLoading ? 'text-amber-400' : 'text-zinc-100 hover:text-amber-400'}"
|
||||
>
|
||||
{novel.title}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-zinc-100 line-clamp-1">{novel.title}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{#if novel.author}
|
||||
<span class="text-xs text-zinc-400">{novel.author}</span>
|
||||
{/if}
|
||||
{#if novel.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">{novel.status}</span>
|
||||
{:else if novel.chapters}
|
||||
<span class="text-xs text-zinc-500">{novel.chapters}</span>
|
||||
{/if}
|
||||
{#if novel.rating}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">★ {novel.rating}</span>
|
||||
{/if}
|
||||
{#if novel.genres?.length}
|
||||
{#each novel.genres.slice(0, 3) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: per-novel scrape button (list view) -->
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="shrink-0">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">Busy</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-red-400 font-medium">Error</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => scrapeNovel(novel)}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- External link -->
|
||||
{#if novel.source_url || novel.url}
|
||||
<a
|
||||
href={novel.source_url ?? novel.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title="Open on novelfire.net"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
|
||||
{#if !isRankView && !isSearchView}
|
||||
{#if hasNext}
|
||||
<!-- Invisible div watched by IntersectionObserver -->
|
||||
<div bind:this={sentinel} class="h-px mt-8"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading spinner while fetching next page -->
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-8">
|
||||
<svg class="w-6 h-6 animate-spin text-amber-400" 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>
|
||||
</div>
|
||||
{:else if !hasNext && novels.length > 0}
|
||||
<p class="text-center text-zinc-600 text-xs mt-8 pb-4">All novels loaded</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Scroll-to-top button -->
|
||||
{#if showScrollTop}
|
||||
<button
|
||||
onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 shadow-lg hover:bg-zinc-700 hover:text-zinc-100 transition-colors"
|
||||
title="Back to top"
|
||||
aria-label="Scroll to top"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user