Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5825b859b7 | ||
|
|
1642434a79 | ||
|
|
02705dc6ed | ||
|
|
7413313100 | ||
|
|
b11f4ab6b4 | ||
|
|
3e4b1c0484 | ||
|
|
b5bc6ff3de | ||
|
|
8d4bba7964 | ||
|
|
2e5fe54615 | ||
|
|
81265510ef | ||
|
|
4d3c093612 | ||
|
|
937ba052fc | ||
|
|
479d201da9 | ||
|
|
1242cc7eb3 | ||
|
|
0b6dbeb042 | ||
|
|
c06877069f | ||
|
|
261c738fc0 | ||
|
|
5528abe4b0 | ||
|
|
09cdda2a07 | ||
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 | ||
|
|
c7b3495a23 | ||
|
|
83a5910a59 | ||
|
|
0f6639aae7 | ||
|
|
88a25bc33e | ||
|
|
73ad4ece49 | ||
|
|
52f876d8e8 | ||
|
|
72eed89f59 | ||
|
|
12bb0db5f0 | ||
|
|
5ec1773768 | ||
|
|
fb8f1dfe25 | ||
|
|
3a2d113b1b | ||
|
|
0dcfdff65b | ||
|
|
1766011b47 | ||
|
|
a6f800b0d7 | ||
|
|
af9639af05 | ||
|
|
bfc08a2df2 | ||
|
|
dc3bc3ebf2 | ||
|
|
e9d7293d37 | ||
|
|
410af8f236 | ||
|
|
264c00c765 | ||
|
|
e4c72011eb | ||
|
|
6365b14ece | ||
|
|
7da5582075 | ||
|
|
dae841e317 | ||
|
|
16b2bfffa6 | ||
|
|
57be674f44 | ||
|
|
93390fab64 | ||
|
|
072517135f | ||
|
|
fe7c7acbb7 | ||
|
|
d4cce915d9 | ||
|
|
ac24e86f7d | ||
|
|
e9bb387f71 | ||
|
|
d7319b3f7c | ||
|
|
f380c85815 | ||
|
|
9d1b340b83 | ||
|
|
a307ddc9f5 | ||
|
|
004d1b6d9d | ||
|
|
7f20411f50 | ||
|
|
6e6c581904 | ||
|
|
cecedc8687 | ||
|
|
a88e98a436 | ||
|
|
d3ae86d55b | ||
|
|
5ad5c2dbce | ||
|
|
0de91dcc0c | ||
|
|
8e3e9ef31d | ||
|
|
3c5edd5742 | ||
|
|
2142e82fe4 | ||
|
|
88cde88f69 | ||
|
|
ffcc3981f2 | ||
|
|
a7b4694e60 | ||
|
|
8c895c6ba1 | ||
|
|
83059c8a9d | ||
|
|
b54ebf60b5 | ||
|
|
e027afe89d | ||
|
|
9fc2054e36 | ||
|
|
9a43b2190e | ||
|
|
5a7d7ce3b9 | ||
|
|
ce3eef1298 | ||
|
|
5d9b41bcf2 | ||
|
|
47268dea67 | ||
|
|
57591766f2 | ||
|
|
fa8fb96631 | ||
|
|
5ba84f7945 | ||
|
|
2793ad8cfa | ||
|
|
e43699747d | ||
|
|
1e85f1c0bc | ||
|
|
0c2349f259 | ||
|
|
c9252b5953 | ||
|
|
7efeee3fc2 | ||
|
|
9a05708019 | ||
|
|
24cb18e0fe | ||
|
|
71ba882858 | ||
|
|
c35f099f50 | ||
|
|
4df287ace4 | ||
|
|
0df45de2b6 | ||
|
|
825fb04c0d | ||
|
|
fc5cd30c93 | ||
|
|
37bd73651a | ||
|
|
466e289b68 | ||
|
|
bb604019fc | ||
|
|
0745178d9e | ||
|
|
603cd2bb02 | ||
|
|
228d4902bb | ||
|
|
884c82b2c3 | ||
|
|
c6536d5b9f | ||
|
|
460e7553bf | ||
|
|
89f0dfb113 | ||
|
|
88644341d8 | ||
|
|
992eb823f2 | ||
|
|
f51113a2f8 | ||
|
|
1eb70e9b9b | ||
|
|
70dd14e5c8 | ||
|
|
8096827c78 | ||
|
|
669fd765ee | ||
|
|
314af375d5 | ||
|
|
20c45e2676 | ||
|
|
09981a5f4d | ||
|
|
de9e0b4246 | ||
|
|
a72c1f6b52 | ||
|
|
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 |
67
.env.example
67
.env.example
@@ -1,6 +1,43 @@
|
||||
# libnovel scraper — environment overrides
|
||||
# Copy to .env and adjust values; do NOT commit this file with real secrets.
|
||||
|
||||
# ── Docker BuildKit ───────────────────────────────────────────────────────────
|
||||
# Required for the backend/Dockerfile cache mounts (--mount=type=cache).
|
||||
# BuildKit is the default in Docker Engine 23+, but Colima users may need this.
|
||||
#
|
||||
# If you see: "the --mount option requires BuildKit", enable it one of two ways:
|
||||
#
|
||||
# Option A — per-project (recommended, zero restart needed):
|
||||
# Uncomment the line below and copy this file to .env.
|
||||
# Docker Compose reads .env automatically, so BuildKit will be active for
|
||||
# every `docker compose build` / `docker compose up --build` in this project.
|
||||
#
|
||||
# Option B — system-wide for Colima (persists across restarts):
|
||||
# echo '{"features":{"buildkit":true}}' > ~/.colima/default/daemon.json
|
||||
# colima stop && colima start
|
||||
#
|
||||
# DOCKER_BUILDKIT=1
|
||||
|
||||
# ── Service ports (host-side) ─────────────────────────────────────────────────
|
||||
# Port the scraper HTTP API listens on (default 8080)
|
||||
SCRAPER_PORT=8080
|
||||
|
||||
# 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 +56,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 +73,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
|
||||
|
||||
79
.gitea/workflows/ci-scraper.yaml
Normal file
79
.gitea/workflows/ci-scraper.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
name: CI / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/ci-scraper.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Run tests
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: scraper
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ gitea.sha }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
70
.gitea/workflows/ci-ui.yaml
Normal file
70
.gitea/workflows/ci-ui.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: CI / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master", "v2"]
|
||||
paths:
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci-ui.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
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
|
||||
|
||||
# ── push to Docker Hub ───────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
@@ -1,106 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/**"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- ".gitea/workflows/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: scraper
|
||||
|
||||
jobs:
|
||||
# ── lint & vet ───────────────────────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: staticcheck
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
staticcheck ./...
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: go test -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── build binary ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o bin/scraper ./cmd/scraper
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
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 }}
|
||||
63
.gitea/workflows/ios.yaml
Normal file
63
.gitea/workflows/ios.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
name: iOS CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["v2", "main"]
|
||||
paths:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
pull_request:
|
||||
branches: ["v2", "main"]
|
||||
paths:
|
||||
- "ios/**"
|
||||
- "justfile"
|
||||
- ".gitea/workflows/ios.yaml"
|
||||
- ".gitea/workflows/ios-release.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ios-macos-runner
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── build (simulator) ─────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Build (simulator)
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-build
|
||||
|
||||
# ── unit tests ────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: macos-latest
|
||||
needs: build
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Run unit tests
|
||||
env:
|
||||
USER: runner
|
||||
run: just ios-test
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: ios/LibNovel/test-results.xml
|
||||
retention-days: 7
|
||||
68
.gitea/workflows/release-scraper.yaml
Normal file
68
.gitea/workflows/release-scraper.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Release / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & test ──────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
- name: Run tests
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── docker build & push ──────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: scraper
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
71
.gitea/workflows/release-ui.yaml
Normal file
71
.gitea/workflows/release-ui.yaml
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Release / UI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── type-check & build ───────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
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]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
163
.gitea/workflows/release-v2.yaml
Normal file
163
.gitea/workflows/release-v2.yaml
Normal file
@@ -0,0 +1,163 @@
|
||||
name: Release / v2
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── backend: lint & test ─────────────────────────────────────────────────────
|
||||
test-backend:
|
||||
name: Test backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: backend
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── ui-v2: type-check & build ────────────────────────────────────────────────
|
||||
build-ui:
|
||||
name: Build ui-v2
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui-v2/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── docker: backend ──────────────────────────────────────────────────────────
|
||||
docker-backend:
|
||||
name: Docker / backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: backend
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
# ── docker: runner ───────────────────────────────────────────────────────────
|
||||
docker-runner:
|
||||
name: Docker / runner
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: runner
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
# ── docker: ui-v2 ────────────────────────────────────────────────────────────
|
||||
docker-ui:
|
||||
name: Docker / ui-v2
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-ui]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui-v2
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui-v2
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
||||
scraper/bin/
|
||||
scraper/scraper
|
||||
|
||||
# ── Scraped output (large, machine-generated) ──────────────────────────────────
|
||||
|
||||
|
||||
156
.opencode/skills/ios-ux/SKILL.md
Normal file
156
.opencode/skills/ios-ux/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: ios-ux
|
||||
description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work.
|
||||
compatibility: opencode
|
||||
---
|
||||
|
||||
# iOS UI/UX Skill — LibNovel
|
||||
|
||||
Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules.
|
||||
|
||||
---
|
||||
|
||||
## Screenshot Review Process
|
||||
|
||||
When the user provides a screenshot of the app:
|
||||
|
||||
1. **Analyze first** — identify specific UI/UX issues across these categories:
|
||||
- Visual hierarchy and spacing
|
||||
- Typography (size, weight, contrast)
|
||||
- Color and material usage
|
||||
- Animation and interactivity gaps
|
||||
- Accessibility problems
|
||||
- Deprecated or non-native patterns
|
||||
2. **Present a numbered list** of suggested improvements with brief rationale for each.
|
||||
3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?"
|
||||
4. Apply only what the user confirms.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
### Colors & Materials
|
||||
- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs.
|
||||
- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`.
|
||||
- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background.
|
||||
- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player).
|
||||
- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors.
|
||||
|
||||
### Typography
|
||||
- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark).
|
||||
- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families.
|
||||
- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier.
|
||||
- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata.
|
||||
|
||||
### Spacing & Layout
|
||||
- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons.
|
||||
- Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements.
|
||||
- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI.
|
||||
- Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art.
|
||||
|
||||
---
|
||||
|
||||
## Animation Rules
|
||||
|
||||
### Spring Animations (default for all interactive transitions)
|
||||
- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions.
|
||||
- Recommended defaults:
|
||||
- Interactive elements: `response: 0.3, dampingFraction: 0.7`
|
||||
- Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7`
|
||||
- Quick snappy feedback: `response: 0.2, dampingFraction: 0.6`
|
||||
- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays).
|
||||
|
||||
### SF Symbol Transitions
|
||||
- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.).
|
||||
- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators).
|
||||
- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing).
|
||||
- Use `.symbolEffect(.pulse)` for error/warning states that need attention.
|
||||
|
||||
### Repeating Animations
|
||||
- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains.
|
||||
- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`.
|
||||
|
||||
---
|
||||
|
||||
## Haptic Feedback
|
||||
|
||||
Add `UIImpactFeedbackGenerator` to every user-initiated interactive control:
|
||||
- `.light` — toggle switches, selection chips, secondary actions, slider drag start.
|
||||
- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations.
|
||||
- `.heavy` — destructive actions (only if no confirmation dialog).
|
||||
|
||||
Pattern:
|
||||
```swift
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
// action
|
||||
} label: { ... }
|
||||
```
|
||||
|
||||
Do **not** add haptics to:
|
||||
- Programmatic state changes not directly triggered by a tap.
|
||||
- Buttons inside `List` rows that already use swipe actions.
|
||||
- Scroll events.
|
||||
|
||||
---
|
||||
|
||||
## iOS 17+ API Usage
|
||||
|
||||
Flag and replace any of the following deprecated patterns:
|
||||
|
||||
| Deprecated | Replace with |
|
||||
|---|---|
|
||||
| `NavigationView` | `NavigationStack` |
|
||||
| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro |
|
||||
| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` |
|
||||
| Manual `@State` animation chains for repeating loops | `phaseAnimator` |
|
||||
| `.animation(_:)` without `value:` | `.animation(_:value:)` |
|
||||
| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` |
|
||||
|
||||
Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
Every view must:
|
||||
- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views.
|
||||
- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment.
|
||||
- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text.
|
||||
- Touch targets ≥ 44pt (see Spacing above).
|
||||
- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`.
|
||||
- Do not rely solely on color to convey state — pair color with icon or label.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds.
|
||||
- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects.
|
||||
- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this.
|
||||
- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache.
|
||||
- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead.
|
||||
|
||||
---
|
||||
|
||||
## Offline & Error States
|
||||
|
||||
Every view that makes network calls must:
|
||||
1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`.
|
||||
2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline.
|
||||
3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`.
|
||||
4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online.
|
||||
|
||||
---
|
||||
|
||||
## Component Checklist (before submitting any view change)
|
||||
|
||||
- [ ] All interactive elements ≥ 44pt touch target
|
||||
- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))`
|
||||
- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)`
|
||||
- [ ] Tappable controls have haptic feedback
|
||||
- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:`
|
||||
- [ ] High-frequency observers are isolated sub-views
|
||||
- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor`
|
||||
- [ ] VoiceOver labels on icon-only buttons
|
||||
- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies
|
||||
171
AGENTS.md
171
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,138 @@ 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
|
||||
- **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
|
||||
|
||||
## Speed Up AI Sessions (Optional)
|
||||
## iOS App
|
||||
|
||||
For faster AI context loading, use **Context7** (free, local indexing):
|
||||
See `ios/AGENTS.md` for full iOS/SwiftUI conventions.
|
||||
|
||||
```bash
|
||||
# Install and index once
|
||||
npx @context7/cli@latest index --path . --ignore .aiignore
|
||||
## Documentation Tools
|
||||
|
||||
# After first run, AI tools will query the index instead of re-scanning files
|
||||
```
|
||||
This project has two MCP-backed documentation tools available. Use them proactively:
|
||||
|
||||
VSCode extension: https://marketplace.visualstudio.com/items?itemName=context7.context7
|
||||
- **`context7`** — Live Apple SwiftUI/Swift docs, Go stdlib, SvelteKit, and any other library docs. Use before implementing anything non-trivial in Swift/SwiftUI. Example: `use context7 to look up NavigationStack`.
|
||||
- **`gh_grep`** — Search real-world code on GitHub for implementation patterns. Example: `use gh_grep to find examples of background URLSession in Swift`.
|
||||
|
||||
13
backend/.dockerignore
Normal file
13
backend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# Exclude compiled binaries
|
||||
bin/
|
||||
|
||||
# Exclude test binaries produced by `go test -c`
|
||||
*.test
|
||||
|
||||
# Git history is not needed inside the image
|
||||
.git/
|
||||
|
||||
# Editor/OS noise
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
42
backend/Dockerfile
Normal file
42
backend/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Download modules into the BuildKit cache so they survive across builds.
|
||||
# This layer is only invalidated when go.mod or go.sum changes.
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
|
||||
# Build all three binaries in a single layer so the Go compiler can reuse
|
||||
# intermediate object files. Both cache mounts are preserved between builds:
|
||||
# /root/go/pkg/mod — downloaded module source
|
||||
# /root/.cache/go-build — compiled package objects (incremental recompile)
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
|
||||
-o /out/backend ./cmd/backend && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
|
||||
-o /out/runner ./cmd/runner && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/healthcheck ./cmd/healthcheck
|
||||
|
||||
# ── backend service ──────────────────────────────────────────────────────────
|
||||
FROM gcr.io/distroless/static:nonroot AS backend
|
||||
COPY --from=builder /out/healthcheck /healthcheck
|
||||
COPY --from=builder /out/backend /backend
|
||||
ENTRYPOINT ["/backend"]
|
||||
|
||||
# ── runner service ───────────────────────────────────────────────────────────
|
||||
FROM gcr.io/distroless/static:nonroot AS runner
|
||||
COPY --from=builder /out/healthcheck /healthcheck
|
||||
COPY --from=builder /out/runner /runner
|
||||
ENTRYPOINT ["/runner"]
|
||||
125
backend/cmd/backend/main.go
Normal file
125
backend/cmd/backend/main.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Command backend is the LibNovel HTTP API server.
|
||||
//
|
||||
// It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads,
|
||||
// scrape-task creation, presigned MinIO URLs, audio-task creation, reading
|
||||
// progress, live novelfire.net browse/search, and Kokoro voice list.
|
||||
//
|
||||
// All heavy lifting (scraping, TTS generation) is delegated to the runner
|
||||
// binary via PocketBase task records. The backend never scrapes directly.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// backend # start HTTP server (blocks until SIGINT/SIGTERM)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/libnovel/backend/internal/backend"
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// version and commit are set at build time via -ldflags.
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "backend: fatal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg := config.Load()
|
||||
|
||||
// ── Logger ───────────────────────────────────────────────────────────────
|
||||
log := buildLogger(cfg.LogLevel)
|
||||
log.Info("backend starting",
|
||||
"version", version,
|
||||
"commit", commit,
|
||||
"addr", cfg.HTTP.Addr,
|
||||
)
|
||||
|
||||
// ── Context: cancel on SIGINT / SIGTERM ──────────────────────────────────
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// ── Storage ──────────────────────────────────────────────────────────────
|
||||
store, err := storage.NewStore(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init storage: %w", err)
|
||||
}
|
||||
|
||||
// ── Kokoro (voice list only; audio generation is done by the runner) ─────
|
||||
var kokoroClient kokoro.Client
|
||||
if cfg.Kokoro.URL != "" {
|
||||
kokoroClient = kokoro.New(cfg.Kokoro.URL)
|
||||
log.Info("kokoro voices enabled", "url", cfg.Kokoro.URL)
|
||||
} else {
|
||||
log.Info("KOKORO_URL not set — voice list will use built-in fallback")
|
||||
kokoroClient = &noopKokoro{}
|
||||
}
|
||||
|
||||
// ── Backend server ───────────────────────────────────────────────────────
|
||||
srv := backend.New(
|
||||
backend.Config{
|
||||
Addr: cfg.HTTP.Addr,
|
||||
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
||||
Version: version,
|
||||
Commit: commit,
|
||||
},
|
||||
backend.Dependencies{
|
||||
BookReader: store,
|
||||
RankingStore: store,
|
||||
AudioStore: store,
|
||||
PresignStore: store,
|
||||
ProgressStore: store,
|
||||
Producer: store,
|
||||
TaskReader: store,
|
||||
Kokoro: kokoroClient,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
return srv.ListenAndServe(ctx)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func buildLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
|
||||
// noopKokoro is a no-op implementation used when KOKORO_URL is not set.
|
||||
// The backend only uses Kokoro for the voice list; audio generation is the
|
||||
// runner's responsibility. With no URL the built-in fallback list is served.
|
||||
type noopKokoro struct{}
|
||||
|
||||
func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
57
backend/cmd/backend/main_test.go
Normal file
57
backend/cmd/backend/main_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildLogger verifies that buildLogger returns a non-nil logger for each
|
||||
// supported log level string and for unknown values.
|
||||
func TestBuildLogger(t *testing.T) {
|
||||
for _, level := range []string{"debug", "info", "warn", "error", "unknown", ""} {
|
||||
l := buildLogger(level)
|
||||
if l == nil {
|
||||
t.Errorf("buildLogger(%q) returned nil", level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoopKokoro verifies that the no-op Kokoro stub returns the expected
|
||||
// sentinel error from GenerateAudio and nil, nil from ListVoices.
|
||||
func TestNoopKokoro(t *testing.T) {
|
||||
noop := &noopKokoro{}
|
||||
|
||||
_, err := noop.GenerateAudio(t.Context(), "text", "af_bella")
|
||||
if err == nil {
|
||||
t.Fatal("noopKokoro.GenerateAudio: expected error, got nil")
|
||||
}
|
||||
|
||||
voices, err := noop.ListVoices(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("noopKokoro.ListVoices: unexpected error: %v", err)
|
||||
}
|
||||
if voices != nil {
|
||||
t.Fatalf("noopKokoro.ListVoices: expected nil slice, got %v", voices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStorageUnreachable verifies that run() fails fast and returns a
|
||||
// descriptive error when PocketBase is unreachable.
|
||||
func TestRunStorageUnreachable(t *testing.T) {
|
||||
// Point at an address nothing is listening on.
|
||||
t.Setenv("POCKETBASE_URL", "http://127.0.0.1:19999")
|
||||
// Use a fast listen address so we don't accidentally start a real server.
|
||||
t.Setenv("BACKEND_HTTP_ADDR", "127.0.0.1:0")
|
||||
|
||||
err := run()
|
||||
if err == nil {
|
||||
t.Fatal("run() should have returned an error when storage is unreachable")
|
||||
}
|
||||
|
||||
t.Logf("got expected error: %v", err)
|
||||
}
|
||||
|
||||
// TestMain runs the test suite. No special setup required.
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
89
backend/cmd/healthcheck/main.go
Normal file
89
backend/cmd/healthcheck/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// healthcheck is a static binary used by Docker HEALTHCHECK CMD in distroless
|
||||
// images (which have no shell, wget, or curl).
|
||||
//
|
||||
// Two modes:
|
||||
//
|
||||
// 1. HTTP mode (default):
|
||||
// /healthcheck <url>
|
||||
// Performs GET <url>; exits 0 if HTTP 2xx/3xx, 1 otherwise.
|
||||
// Example: /healthcheck http://localhost:8080/health
|
||||
//
|
||||
// 2. File-liveness mode:
|
||||
// /healthcheck file <path> <max_age_seconds>
|
||||
// Reads <path>, parses its content as RFC3339 timestamp, and exits 1 if the
|
||||
// timestamp is older than <max_age_seconds>. Used by the runner service which
|
||||
// writes /tmp/runner.alive on every successful poll.
|
||||
// Example: /healthcheck file /tmp/runner.alive 120
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "file" {
|
||||
checkFile()
|
||||
return
|
||||
}
|
||||
checkHTTP()
|
||||
}
|
||||
|
||||
// checkHTTP performs a GET request and exits 0 on success, 1 on failure.
|
||||
func checkHTTP() {
|
||||
url := "http://localhost:8080/health"
|
||||
if len(os.Args) > 1 {
|
||||
url = os.Args[1]
|
||||
}
|
||||
resp, err := http.Get(url) //nolint:gosec,noctx
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck: status %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// checkFile reads a timestamp from a file and exits 1 if it is older than the
|
||||
// given max age. Usage: /healthcheck file <path> <max_age_seconds>
|
||||
func checkFile() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Fprintln(os.Stderr, "healthcheck file: usage: /healthcheck file <path> <max_age_seconds>")
|
||||
os.Exit(1)
|
||||
}
|
||||
path := os.Args[2]
|
||||
maxAgeSec, err := strconv.ParseInt(os.Args[3], 10, 64)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck file: invalid max_age_seconds %q: %v\n", os.Args[3], err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck file: cannot read %s: %v\n", path, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ts, err := time.Parse(time.RFC3339, string(data))
|
||||
if err != nil {
|
||||
// Fallback: use file mtime if content is not a valid timestamp.
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck file: cannot stat %s: %v\n", path, statErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
ts = info.ModTime()
|
||||
}
|
||||
|
||||
age := time.Since(ts)
|
||||
if age > time.Duration(maxAgeSec)*time.Second {
|
||||
fmt.Fprintf(os.Stderr, "healthcheck file: %s is %.0fs old (max %ds)\n", path, age.Seconds(), maxAgeSec)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
139
backend/cmd/runner/main.go
Normal file
139
backend/cmd/runner/main.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Command runner is the homelab worker binary.
|
||||
//
|
||||
// It polls PocketBase for pending scrape and audio tasks, executes them, and
|
||||
// writes results back. It connects directly to PocketBase and MinIO using
|
||||
// admin credentials loaded from environment variables.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// runner # start polling loop (blocks until SIGINT/SIGTERM)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/browser"
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/novelfire"
|
||||
"github.com/libnovel/backend/internal/runner"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// version and commit are set at build time via -ldflags.
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "runner: fatal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg := config.Load()
|
||||
|
||||
// ── Logger ──────────────────────────────────────────────────────────────
|
||||
log := buildLogger(cfg.LogLevel)
|
||||
log.Info("runner starting",
|
||||
"version", version,
|
||||
"commit", commit,
|
||||
"worker_id", cfg.Runner.WorkerID,
|
||||
)
|
||||
|
||||
// ── Context: cancel on SIGINT / SIGTERM ─────────────────────────────────
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// ── Storage ─────────────────────────────────────────────────────────────
|
||||
store, err := storage.NewStore(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init storage: %w", err)
|
||||
}
|
||||
|
||||
// ── Browser / Scraper ───────────────────────────────────────────────────
|
||||
workers := cfg.Runner.Workers
|
||||
if workers <= 0 {
|
||||
workers = runtime.NumCPU()
|
||||
}
|
||||
timeout := cfg.Runner.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 90 * time.Second
|
||||
}
|
||||
|
||||
browserClient := browser.NewDirectClient(browser.Config{
|
||||
MaxConcurrent: workers,
|
||||
Timeout: timeout,
|
||||
ProxyURL: cfg.Runner.ProxyURL,
|
||||
})
|
||||
novel := novelfire.New(browserClient, log)
|
||||
|
||||
// ── Kokoro ──────────────────────────────────────────────────────────────
|
||||
var kokoroClient kokoro.Client
|
||||
if cfg.Kokoro.URL != "" {
|
||||
kokoroClient = kokoro.New(cfg.Kokoro.URL)
|
||||
log.Info("kokoro TTS enabled", "url", cfg.Kokoro.URL)
|
||||
} else {
|
||||
log.Warn("KOKORO_URL not set — audio tasks will fail")
|
||||
kokoroClient = &noopKokoro{}
|
||||
}
|
||||
|
||||
// ── Runner ──────────────────────────────────────────────────────────────
|
||||
rCfg := runner.Config{
|
||||
WorkerID: cfg.Runner.WorkerID,
|
||||
PollInterval: cfg.Runner.PollInterval,
|
||||
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
|
||||
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
|
||||
OrchestratorWorkers: workers,
|
||||
}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: store,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
return r.Run(ctx)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func buildLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
||||
}
|
||||
|
||||
// noopKokoro is a no-op implementation used when KOKORO_URL is not set.
|
||||
type noopKokoro struct{}
|
||||
|
||||
func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
29
backend/go.mod
Normal file
29
backend/go.mod
Normal file
@@ -0,0 +1,29 @@
|
||||
module github.com/libnovel/backend
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
golang.org/x/net v0.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
45
backend/go.sum
Normal file
45
backend/go.sum
Normal file
@@ -0,0 +1,45 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
BIN
backend/healthcheck
Executable file
BIN
backend/healthcheck
Executable file
Binary file not shown.
937
backend/internal/backend/handlers.go
Normal file
937
backend/internal/backend/handlers.go
Normal file
@@ -0,0 +1,937 @@
|
||||
package backend
|
||||
|
||||
// handlers.go — all HTTP request handlers for the backend server.
|
||||
//
|
||||
// Handler naming mirrors the route table in server.go:
|
||||
// handleScrapeCatalogue, handleScrapeBook, handleScrapeBookRange
|
||||
// handleScrapeStatus, handleScrapeTasks
|
||||
// handleBrowse, handleSearch
|
||||
// handleGetRanking, handleGetCover
|
||||
// handleBookPreview, handleChapterText, handleReindex
|
||||
// handleChapterText, handleReindex
|
||||
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
|
||||
// handleVoices
|
||||
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
|
||||
// handlePresignAvatarUpload, handlePresignAvatar
|
||||
// handleGetProgress, handleSetProgress, handleDeleteProgress
|
||||
//
|
||||
// Key design choices vs. old scraper:
|
||||
// - POST /scrape* creates a PocketBase task record and returns 202 with the
|
||||
// task_id — it does NOT run the orchestrator inline.
|
||||
// - POST /api/audio creates a PocketBase audio task and returns 202 — the
|
||||
// runner binary executes TTS generation asynchronously.
|
||||
// - GET /api/audio/status polls PocketBase for the task record status.
|
||||
// - GET /api/audio-proxy reads the completed audio object from MinIO via a
|
||||
// presigned URL redirect (the runner has already uploaded the bytes).
|
||||
// - GET /api/browse and /api/search fetch novelfire.net live (no MinIO cache).
|
||||
// - GET /api/cover redirects to the source cover URL live.
|
||||
// - GET /api/ranking reads from the PocketBase ranking collection (populated
|
||||
// by the runner after each catalogue scrape).
|
||||
// - GET /api/book-preview returns stored data when in library, or enqueues a
|
||||
// scrape task and returns 202 when not. The backend never scrapes directly.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
)
|
||||
|
||||
const (
|
||||
novelFireBase = "https://novelfire.net"
|
||||
novelFireDomain = "novelfire.net"
|
||||
)
|
||||
|
||||
// ── Scrape task creation ───────────────────────────────────────────────────────
|
||||
|
||||
// handleScrapeCatalogue handles POST /scrape.
|
||||
// Creates a "catalogue" scrape task in PocketBase and returns 202 with the task ID.
|
||||
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "catalogue", "", 0, 0)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleScrapeCatalogue: CreateScrapeTask failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create task")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
|
||||
}
|
||||
|
||||
// handleScrapeBook handles POST /scrape/book.
|
||||
// Body: {"url": "https://novelfire.net/book/..."}
|
||||
func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
|
||||
jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`)
|
||||
return
|
||||
}
|
||||
taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book", body.URL, 0, 0)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleScrapeBook: CreateScrapeTask failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create task")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
|
||||
}
|
||||
|
||||
// handleScrapeBookRange handles POST /scrape/book/range.
|
||||
// Body: {"url": "...", "from": N, "to": M}
|
||||
func (s *Server) handleScrapeBookRange(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
|
||||
jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`)
|
||||
return
|
||||
}
|
||||
taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book_range", body.URL, body.From, body.To)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleScrapeBookRange: CreateScrapeTask failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create task")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
|
||||
}
|
||||
|
||||
// handleCancelTask handles POST /api/cancel-task/{id}.
|
||||
// Transitions a pending task (scrape or audio) to status=cancelled.
|
||||
// Returns 404 if the task does not exist, 409 if it cannot be cancelled
|
||||
// (e.g. already running/done).
|
||||
func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing task id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Producer.CancelTask(r.Context(), id); err != nil {
|
||||
s.deps.Log.Warn("handleCancelTask: CancelTask failed", "id", id, "err", err)
|
||||
jsonError(w, http.StatusConflict, "could not cancel task: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled", "id": id})
|
||||
}
|
||||
|
||||
// ── Scrape task status / history ───────────────────────────────────────────────
|
||||
|
||||
// handleScrapeStatus handles GET /api/scrape/status.
|
||||
// Returns the most recent scrape task status (or {"running":false} if none).
|
||||
func (s *Server) handleScrapeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleScrapeStatus: ListScrapeTasks failed", "err", err)
|
||||
writeJSON(w, 0, map[string]bool{"running": false})
|
||||
return
|
||||
}
|
||||
running := false
|
||||
for _, t := range tasks {
|
||||
if t.Status == domain.TaskStatusRunning || t.Status == domain.TaskStatusPending {
|
||||
running = true
|
||||
break
|
||||
}
|
||||
}
|
||||
writeJSON(w, 0, map[string]bool{"running": running})
|
||||
}
|
||||
|
||||
// handleScrapeTasks handles GET /api/scrape/tasks.
|
||||
// Returns all scrape task records from PocketBase, newest first.
|
||||
func (s *Server) handleScrapeTasks(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleScrapeTasks: ListScrapeTasks failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list tasks")
|
||||
return
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []domain.ScrapeTask{}
|
||||
}
|
||||
writeJSON(w, 0, tasks)
|
||||
}
|
||||
|
||||
// ── Browse & search ────────────────────────────────────────────────────────────
|
||||
|
||||
// NovelListing represents a single novel entry from the novelfire browse/search page.
|
||||
type NovelListing struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
Rank string `json:"rank"`
|
||||
Rating string `json:"rating"`
|
||||
Chapters string `json:"chapters"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleBrowse handles GET /api/browse.
|
||||
// Fetches novelfire.net live (no MinIO cache in the new backend).
|
||||
// Query params: page (default 1), genre (default "all"), sort (default "popular"),
|
||||
// status (default "all"), type (default "all-novel")
|
||||
func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := q.Get("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
genre := q.Get("genre")
|
||||
if genre == "" {
|
||||
genre = "all"
|
||||
}
|
||||
sortBy := q.Get("sort")
|
||||
if sortBy == "" {
|
||||
sortBy = "popular"
|
||||
}
|
||||
status := q.Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
novelType := q.Get("type")
|
||||
if novelType == "" {
|
||||
novelType = "all-novel"
|
||||
}
|
||||
|
||||
pageNum, _ := strconv.Atoi(page)
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
|
||||
targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d",
|
||||
novelFireBase, genre, sortBy, status, novelType, pageNum)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
novels, hasNext, err := s.fetchBrowsePage(ctx, targetURL)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleBrowse: fetch failed", "url", targetURL, "err", err)
|
||||
jsonError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"novels": novels,
|
||||
"page": pageNum,
|
||||
"hasNext": hasNext,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSearch handles GET /api/search.
|
||||
// Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all")
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if len([]rune(q)) < 2 {
|
||||
jsonError(w, http.StatusBadRequest, "query must be at least 2 characters")
|
||||
return
|
||||
}
|
||||
|
||||
source := r.URL.Query().Get("source")
|
||||
if source == "" {
|
||||
source = "all"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var localResults, remoteResults []NovelListing
|
||||
|
||||
// Local search (PocketBase books)
|
||||
if source == "local" || source == "all" {
|
||||
books, err := s.deps.BookReader.ListBooks(ctx)
|
||||
if err != nil {
|
||||
s.deps.Log.Warn("search: ListBooks failed", "err", err)
|
||||
} else {
|
||||
qLower := strings.ToLower(q)
|
||||
for _, b := range books {
|
||||
if strings.Contains(strings.ToLower(b.Title), qLower) ||
|
||||
strings.Contains(strings.ToLower(b.Author), qLower) {
|
||||
localResults = append(localResults, NovelListing{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Cover: b.Cover,
|
||||
URL: b.SourceURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remote search (novelfire.net)
|
||||
if source == "remote" || source == "all" {
|
||||
searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
parsed, _ := parseBrowsePage(resp.Body)
|
||||
remoteResults = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: local first, de-duplicate remote
|
||||
localSlugs := make(map[string]bool, len(localResults))
|
||||
for _, item := range localResults {
|
||||
localSlugs[item.Slug] = true
|
||||
}
|
||||
combined := make([]NovelListing, 0, len(localResults)+len(remoteResults))
|
||||
combined = append(combined, localResults...)
|
||||
for _, item := range remoteResults {
|
||||
if !localSlugs[item.Slug] {
|
||||
combined = append(combined, item)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"results": combined,
|
||||
"local_count": len(localResults),
|
||||
"remote_count": len(remoteResults),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Ranking ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleGetRanking handles GET /api/ranking.
|
||||
// Returns all ranking items sorted by rank ascending.
|
||||
func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.deps.RankingStore.ReadRankingItems(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleGetRanking: ReadRankingItems failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to read ranking")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []domain.RankingItem{}
|
||||
}
|
||||
writeJSON(w, 0, items)
|
||||
}
|
||||
|
||||
// handleGetCover handles GET /api/cover/{domain}/{slug}.
|
||||
// The new backend does not cache covers in MinIO. Instead it redirects the
|
||||
// client to the novelfire.net source URL. The domain path segment is kept for
|
||||
// API compatibility with the old scraper.
|
||||
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
http.Error(w, "missing slug", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Redirect to the standard novelfire cover CDN URL. If the caller has the
|
||||
// actual cover URL stored in metadata they should use it directly; this
|
||||
// endpoint is a best-effort fallback.
|
||||
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
|
||||
http.Redirect(w, r, coverURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// ── Preview (live scrape, no store writes) ─────────────────────────────────────
|
||||
|
||||
// handleBookPreview handles GET /api/book-preview/{slug}.
|
||||
//
|
||||
// If the book is already in the library (PocketBase), returns its metadata and
|
||||
// chapter index immediately (200).
|
||||
//
|
||||
// If the book is not yet in the library, enqueues a "book" scrape task and
|
||||
// returns 202 Accepted with the task_id. The runner will scrape the book
|
||||
// asynchronously; the client should poll GET /api/scrape/status or
|
||||
// GET /api/scrape/tasks to detect completion, then re-request this endpoint.
|
||||
//
|
||||
// The backend never scrapes directly — all scraping is the runner's job.
|
||||
func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err)
|
||||
inLib = false
|
||||
}
|
||||
|
||||
if inLib {
|
||||
// Fast path: book is already scraped — return stored data.
|
||||
chapters, cerr := s.deps.BookReader.ListChapters(ctx, slug)
|
||||
if cerr != nil {
|
||||
s.deps.Log.Warn("book-preview: ListChapters failed", "slug", slug, "err", cerr)
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"in_lib": true,
|
||||
"meta": meta,
|
||||
"chapters": chapters,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Book not in library — enqueue a range scrape task for the first 20 chapters
|
||||
// so the user can start reading quickly. Remaining chapters can be scraped
|
||||
// later via the book detail page or the admin scrape panel.
|
||||
bookURL := r.URL.Query().Get("source_url")
|
||||
if bookURL == "" {
|
||||
bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug)
|
||||
}
|
||||
|
||||
const previewFrom, previewTo = 1, 20
|
||||
taskID, err := s.deps.Producer.CreateScrapeTask(ctx, "book_range", bookURL, previewFrom, previewTo)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("book-preview: CreateScrapeTask failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to enqueue scrape task")
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book-preview: enqueued range scrape task", "slug", slug, "task_id", taskID,
|
||||
"from", previewFrom, "to", previewTo)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"in_lib": false,
|
||||
"task_id": taskID,
|
||||
"message": fmt.Sprintf("scraping first %d chapters; poll /api/scrape/tasks for completion", previewTo),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Chapter text ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleChapterText handles GET /api/chapter-text/{slug}/{n}.
|
||||
// Returns plain text (markdown stripped) of a stored chapter.
|
||||
func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, stripMarkdown(raw))
|
||||
}
|
||||
|
||||
// handleChapterMarkdown handles GET /api/chapter-markdown/{slug}/{n}.
|
||||
//
|
||||
// Returns the raw markdown content of a stored chapter directly from MinIO.
|
||||
// This is used by the SvelteKit UI as a simpler alternative to presign+fetch:
|
||||
// it avoids the need for the SvelteKit server to reach MinIO directly, and
|
||||
// gives a clean 404 when the chapter has not been scraped yet.
|
||||
func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
s.deps.Log.Warn("chapter-markdown: not found in MinIO", "slug", slug, "n", n, "err", err)
|
||||
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, raw)
|
||||
}
|
||||
|
||||
// handleReindex handles POST /api/reindex/{slug}.
|
||||
// Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects.
|
||||
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
count, err := s.deps.BookReader.ReindexChapters(r.Context(), slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("reindex failed", "slug", slug, "indexed", count, "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{
|
||||
"error": err.Error(),
|
||||
"indexed": count,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("reindex complete", "slug", slug, "indexed", count)
|
||||
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
|
||||
}
|
||||
|
||||
// ── Audio ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
||||
// Creates an audio_jobs task in PocketBase (runner executes asynchronously).
|
||||
// Returns 200 immediately if audio already exists in MinIO.
|
||||
// Returns 202 with the task_id if a new task was created.
|
||||
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid chapter")
|
||||
return
|
||||
}
|
||||
|
||||
voice := s.cfg.DefaultVoice
|
||||
var body struct {
|
||||
Voice string `json:"voice"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
if body.Voice != "" {
|
||||
voice = body.Voice
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
||||
|
||||
// Fast path: audio already in MinIO
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
|
||||
writeJSON(w, 0, map[string]string{"url": proxyURL, "status": "done"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a task is already pending/running
|
||||
task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
|
||||
if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"task_id": task.ID,
|
||||
"status": string(task.Status),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new audio task
|
||||
taskID, err := s.deps.Producer.CreateAudioTask(r.Context(), slug, n, voice)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioGenerate: CreateAudioTask failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create audio task")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"task_id": taskID,
|
||||
"status": "pending",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
|
||||
// Polls PocketBase for the audio task status.
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
// Fast path: audio exists in MinIO
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
|
||||
writeJSON(w, 0, map[string]string{
|
||||
"status": "done",
|
||||
"url": proxyURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
||||
task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
|
||||
if !found {
|
||||
writeJSON(w, 0, map[string]string{"status": "idle"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]string{
|
||||
"status": string(task.Status),
|
||||
"task_id": task.ID,
|
||||
}
|
||||
if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" {
|
||||
resp["error"] = task.ErrorMessage
|
||||
}
|
||||
writeJSON(w, 0, resp)
|
||||
}
|
||||
|
||||
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
||||
// Redirects to a presigned MinIO URL for the generated audio object.
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
if !s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioProxy: PresignAudio failed", "slug", slug, "n", n, "err", err)
|
||||
http.Error(w, "presign failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, presignURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleVoices handles GET /api/voices.
|
||||
// Returns {"voices": [...]} — fetched from Kokoro with built-in fallback.
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
|
||||
}
|
||||
|
||||
// ── Presigned URLs ─────────────────────────────────────────────────────────────
|
||||
|
||||
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
|
||||
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.deps.PresignStore.PresignChapter(r.Context(), slug, n, 15*time.Minute)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"url": u})
|
||||
}
|
||||
|
||||
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
|
||||
// Query params: voice (optional)
|
||||
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
key := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"url": u})
|
||||
}
|
||||
|
||||
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
||||
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
||||
voice := r.PathValue("voice")
|
||||
if voice == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing voice")
|
||||
return
|
||||
}
|
||||
|
||||
key := kokoro.VoiceSampleKey(voice)
|
||||
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("presign voice sample failed", "voice", voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"url": u})
|
||||
}
|
||||
|
||||
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
|
||||
// Query params: ext (jpg|png|webp, defaults to jpg)
|
||||
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("userId")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing userId")
|
||||
return
|
||||
}
|
||||
|
||||
ext := r.URL.Query().Get("ext")
|
||||
switch ext {
|
||||
case "jpg", "jpeg":
|
||||
ext = "jpg"
|
||||
case "png":
|
||||
ext = "png"
|
||||
case "webp":
|
||||
ext = "webp"
|
||||
default:
|
||||
ext = "jpg"
|
||||
}
|
||||
|
||||
uploadURL, key, err := s.deps.PresignStore.PresignAvatarUpload(r.Context(), userID, ext)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("presign avatar upload failed", "userId", userID, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"upload_url": uploadURL, "key": key})
|
||||
}
|
||||
|
||||
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
|
||||
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.PathValue("userId")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing userId")
|
||||
return
|
||||
}
|
||||
|
||||
u, found, err := s.deps.PresignStore.PresignAvatarURL(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("presign avatar failed", "userId", userID, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"url": u})
|
||||
}
|
||||
|
||||
// ── Progress ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleGetProgress handles GET /api/progress.
|
||||
// Returns {"slug": chapterNum, "slug_ts": timestampMs, ...}
|
||||
func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ensureSession(w, r)
|
||||
entries, err := s.deps.ProgressStore.AllProgress(r.Context(), sid)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("AllProgress failed", "err", err)
|
||||
entries = nil
|
||||
}
|
||||
|
||||
progress := make(map[string]any, len(entries)*2)
|
||||
for _, p := range entries {
|
||||
progress[p.Slug] = p.Chapter
|
||||
progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli()
|
||||
}
|
||||
writeJSON(w, 0, progress)
|
||||
}
|
||||
|
||||
// handleSetProgress handles POST /api/progress/{slug}.
|
||||
// Body: {"chapter": N}
|
||||
func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ensureSession(w, r)
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Chapter int `json:"chapter"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
p := domain.ReadingProgress{
|
||||
Slug: slug,
|
||||
Chapter: body.Chapter,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := s.deps.ProgressStore.SetProgress(r.Context(), sid, p); err != nil {
|
||||
s.deps.Log.Error("SetProgress failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "store error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{})
|
||||
}
|
||||
|
||||
// handleDeleteProgress handles DELETE /api/progress/{slug}.
|
||||
func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ensureSession(w, r)
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.ProgressStore.DeleteProgress(r.Context(), sid, slug); err != nil {
|
||||
s.deps.Log.Error("DeleteProgress failed", "slug", slug, "err", err)
|
||||
// non-fatal
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{})
|
||||
}
|
||||
|
||||
// ── Browse page parsing helpers ────────────────────────────────────────────────
|
||||
|
||||
// fetchBrowsePage fetches pageURL and parses NovelListings from the HTML.
|
||||
func (s *Server) fetchBrowsePage(ctx context.Context, pageURL string) ([]NovelListing, bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetch %s: %w", pageURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, false, fmt.Errorf("upstream returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
novels, hasNext := parseBrowsePage(resp.Body)
|
||||
return novels, hasNext, nil
|
||||
}
|
||||
|
||||
// parseBrowsePage parses a novelfire HTML body and returns novel listings.
|
||||
// It uses a simple string-scanning approach to avoid importing golang.org/x/net/html
|
||||
// in this package (that dependency is only in internal/novelfire).
|
||||
func parseBrowsePage(r io.Reader) ([]NovelListing, bool) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
body := string(data)
|
||||
|
||||
var novels []NovelListing
|
||||
hasNext := false
|
||||
|
||||
// Detect "next page" link
|
||||
if strings.Contains(body, `rel="next"`) ||
|
||||
strings.Contains(body, `aria-label="Next"`) ||
|
||||
strings.Contains(body, `class="next"`) {
|
||||
hasNext = true
|
||||
}
|
||||
|
||||
// Extract novel slugs and titles using simple regex patterns.
|
||||
// novelfire.net novel items: <li class="novel-item">...</li>
|
||||
// Each contains an anchor like <a href="/book/{slug}">
|
||||
slugRe := regexp.MustCompile(`href="/book/([^/"]+)"`)
|
||||
titleRe := regexp.MustCompile(`class="novel-title[^"]*"[^>]*>([^<]+)<`)
|
||||
coverRe := regexp.MustCompile(`data-src="(https?://[^"]+)"`)
|
||||
|
||||
slugMatches := slugRe.FindAllStringSubmatch(body, -1)
|
||||
titleMatches := titleRe.FindAllStringSubmatch(body, -1)
|
||||
coverMatches := coverRe.FindAllStringSubmatch(body, -1)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for i, sm := range slugMatches {
|
||||
slug := sm[1]
|
||||
if seen[slug] {
|
||||
continue
|
||||
}
|
||||
seen[slug] = true
|
||||
|
||||
novel := NovelListing{
|
||||
Slug: slug,
|
||||
URL: novelFireBase + "/book/" + slug,
|
||||
}
|
||||
if i < len(titleMatches) {
|
||||
novel.Title = strings.TrimSpace(titleMatches[i][1])
|
||||
}
|
||||
if i < len(coverMatches) {
|
||||
novel.Cover = coverMatches[i][1]
|
||||
}
|
||||
if novel.Title != "" {
|
||||
novels = append(novels, novel)
|
||||
}
|
||||
}
|
||||
|
||||
return novels, hasNext
|
||||
}
|
||||
|
||||
// ── Markdown stripping ─────────────────────────────────────────────────────────
|
||||
|
||||
// stripMarkdown removes common markdown syntax from src, returning plain text.
|
||||
func stripMarkdown(src string) string {
|
||||
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
|
||||
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
|
||||
|
||||
// kokoroVoices is the built-in fallback list used when the Kokoro service is
|
||||
// unavailable. Matches the list in the old scraper helpers.go.
|
||||
var kokoroVoices = []string{
|
||||
// American English
|
||||
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
|
||||
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
|
||||
"af_sarah", "af_sky",
|
||||
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
|
||||
"am_michael", "am_onyx", "am_puck",
|
||||
// British English
|
||||
"bf_alice", "bf_emma", "bf_lily",
|
||||
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
|
||||
// Spanish
|
||||
"ef_dora", "em_alex",
|
||||
// French
|
||||
"ff_siwis",
|
||||
// Hindi
|
||||
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
|
||||
// Italian
|
||||
"if_sara", "im_nicola",
|
||||
// Japanese
|
||||
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
|
||||
// Portuguese
|
||||
"pf_dora", "pm_alex",
|
||||
// Chinese
|
||||
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
|
||||
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
|
||||
}
|
||||
285
backend/internal/backend/server.go
Normal file
285
backend/internal/backend/server.go
Normal file
@@ -0,0 +1,285 @@
|
||||
// Package backend implements the HTTP API server for the LibNovel backend.
|
||||
//
|
||||
// The server exposes all endpoints consumed by the SvelteKit UI:
|
||||
// - Book/chapter reads from PocketBase/MinIO via bookstore interfaces
|
||||
// - Task creation (scrape + audio) via taskqueue.Producer — the runner binary
|
||||
// picks up and executes those tasks asynchronously
|
||||
// - Presigned MinIO URLs for media playback/upload
|
||||
// - Session-scoped reading progress
|
||||
// - Live novelfire.net browse/search (no scraper interface needed; direct HTTP)
|
||||
// - Kokoro voice list
|
||||
//
|
||||
// The backend never scrapes directly. All scraping (metadata, chapter list,
|
||||
// chapter text, audio TTS) is delegated to the runner binary via PocketBase
|
||||
// task records. GET /api/book-preview enqueues a task when the book is absent.
|
||||
//
|
||||
// All external dependencies are injected as interfaces; concrete types live in
|
||||
// internal/storage and are wired by cmd/backend/main.go.
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
// Dependencies holds all external services the backend server depends on.
|
||||
// Every field is an interface so test doubles can be injected freely.
|
||||
type Dependencies struct {
|
||||
// BookReader reads book metadata and chapter text from PocketBase/MinIO.
|
||||
BookReader bookstore.BookReader
|
||||
// RankingStore reads ranking data from PocketBase.
|
||||
RankingStore bookstore.RankingStore
|
||||
// AudioStore checks audio object existence and computes MinIO keys.
|
||||
AudioStore bookstore.AudioStore
|
||||
// PresignStore generates short-lived MinIO URLs.
|
||||
PresignStore bookstore.PresignStore
|
||||
// ProgressStore reads/writes per-session reading progress.
|
||||
ProgressStore bookstore.ProgressStore
|
||||
// Producer creates scrape/audio tasks in PocketBase.
|
||||
Producer taskqueue.Producer
|
||||
// TaskReader reads scrape/audio task records from PocketBase.
|
||||
TaskReader taskqueue.Reader
|
||||
// Kokoro is the TTS client (used for voice list only in the backend;
|
||||
// audio generation is done by the runner).
|
||||
Kokoro kokoro.Client
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Config holds HTTP server tuning parameters.
|
||||
type Config struct {
|
||||
// Addr is the listen address, e.g. ":8080".
|
||||
Addr string
|
||||
// DefaultVoice is used when no voice is specified in audio requests.
|
||||
DefaultVoice string
|
||||
// Version and Commit are embedded in /health and /api/version responses.
|
||||
Version string
|
||||
Commit string
|
||||
}
|
||||
|
||||
// Server is the HTTP API server.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
deps Dependencies
|
||||
|
||||
// voiceMu guards cachedVoices. Populated lazily on first GET /api/voices.
|
||||
voiceMu sync.RWMutex
|
||||
cachedVoices []string
|
||||
}
|
||||
|
||||
// New creates a Server from cfg and deps.
|
||||
func New(cfg Config, deps Dependencies) *Server {
|
||||
if cfg.DefaultVoice == "" {
|
||||
cfg.DefaultVoice = "af_bella"
|
||||
}
|
||||
if deps.Log == nil {
|
||||
deps.Log = slog.Default()
|
||||
}
|
||||
return &Server{cfg: cfg, deps: deps}
|
||||
}
|
||||
|
||||
// ListenAndServe registers all routes and starts the HTTP server.
|
||||
// It blocks until ctx is cancelled, then performs a graceful shutdown.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health / version
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/version", s.handleVersion)
|
||||
|
||||
// Scrape task creation (202 Accepted — runner executes asynchronously)
|
||||
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
||||
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
||||
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
||||
|
||||
// Scrape task status / history
|
||||
mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus)
|
||||
mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)
|
||||
|
||||
// Cancel a pending task (scrape or audio)
|
||||
mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask)
|
||||
|
||||
// Browse & search (live novelfire.net)
|
||||
mux.HandleFunc("GET /api/browse", s.handleBrowse)
|
||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||
|
||||
// Ranking (from PocketBase)
|
||||
mux.HandleFunc("GET /api/ranking", s.handleGetRanking)
|
||||
|
||||
// Cover proxy (live URL redirect)
|
||||
mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover)
|
||||
|
||||
// Book preview (enqueues scrape task if not in library; returns stored data if already scraped)
|
||||
mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview)
|
||||
|
||||
// Chapter text (served from MinIO via PocketBase index)
|
||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||
// Raw markdown chapter content — served directly from MinIO by the backend.
|
||||
// Use this instead of presign+fetch to avoid SvelteKit→MinIO network path.
|
||||
mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown)
|
||||
|
||||
// Reindex chapters_idx from MinIO
|
||||
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
||||
|
||||
// Audio task creation (backend creates task; runner executes)
|
||||
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
|
||||
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
|
||||
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
// Presigned URLs
|
||||
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
||||
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
||||
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
||||
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
|
||||
// Reading progress
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.cfg.Addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.ListenAndServe() }()
|
||||
s.deps.Log.Info("backend: HTTP server listening", "addr", s.cfg.Addr)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.deps.Log.Info("backend: context cancelled, starting graceful shutdown")
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutCtx); err != nil {
|
||||
s.deps.Log.Error("backend: graceful shutdown failed", "err", err)
|
||||
return err
|
||||
}
|
||||
s.deps.Log.Info("backend: shutdown complete")
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session cookie helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const sessionCookieName = "libnovel_session"
|
||||
|
||||
func sessionID(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func newSessionID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ensureSession(w http.ResponseWriter, r *http.Request) string {
|
||||
if id := sessionID(r); id != "" {
|
||||
return id
|
||||
}
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
id = fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 365 * 24 * 60 * 60,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Utility helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// writeJSON writes v as a JSON response with status code. Status 0 → 200.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if status != 0 {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// jsonError writes a JSON error body and the given status code.
|
||||
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// voices returns the list of available Kokoro voices. On the first call it
|
||||
// fetches from the Kokoro service and caches the result. Falls back to the
|
||||
// hardcoded list on error.
|
||||
func (s *Server) voices(ctx context.Context) []string {
|
||||
s.voiceMu.RLock()
|
||||
cached := s.cachedVoices
|
||||
s.voiceMu.RUnlock()
|
||||
if len(cached) > 0 {
|
||||
return cached
|
||||
}
|
||||
|
||||
if s.deps.Kokoro == nil {
|
||||
return kokoroVoices
|
||||
}
|
||||
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
list, err := s.deps.Kokoro.ListVoices(fetchCtx)
|
||||
if err != nil || len(list) == 0 {
|
||||
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
|
||||
return kokoroVoices
|
||||
}
|
||||
|
||||
s.voiceMu.Lock()
|
||||
s.cachedVoices = list
|
||||
s.voiceMu.Unlock()
|
||||
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(list))
|
||||
return list
|
||||
}
|
||||
|
||||
// handleHealth handles GET /health.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, 0, map[string]string{
|
||||
"status": "ok",
|
||||
"version": s.cfg.Version,
|
||||
"commit": s.cfg.Commit,
|
||||
})
|
||||
}
|
||||
|
||||
// handleVersion handles GET /api/version.
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, 0, map[string]string{
|
||||
"version": s.cfg.Version,
|
||||
"commit": s.cfg.Commit,
|
||||
})
|
||||
}
|
||||
125
backend/internal/bookstore/bookstore.go
Normal file
125
backend/internal/bookstore/bookstore.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package bookstore defines the segregated read/write interfaces for book,
|
||||
// chapter, ranking, progress, audio, and presign data.
|
||||
//
|
||||
// Interface segregation:
|
||||
// - BookWriter — used by the runner to persist scraped data.
|
||||
// - BookReader — used by the backend to serve book/chapter data.
|
||||
// - RankingStore — used by both runner (write) and backend (read).
|
||||
// - PresignStore — used only by the backend for URL signing.
|
||||
// - AudioStore — used by the runner to store audio; backend for presign.
|
||||
// - ProgressStore— used only by the backend for reading progress.
|
||||
//
|
||||
// Concrete implementations live in internal/storage.
|
||||
package bookstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// BookWriter is the write side used by the runner after scraping a book.
|
||||
type BookWriter interface {
|
||||
// WriteMetadata upserts all bibliographic fields for a book.
|
||||
WriteMetadata(ctx context.Context, meta domain.BookMeta) error
|
||||
|
||||
// WriteChapter stores a fully-scraped chapter's text in MinIO and
|
||||
// updates the chapters_idx record in PocketBase.
|
||||
WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error
|
||||
|
||||
// WriteChapterRefs persists chapter metadata (number + title) into
|
||||
// chapters_idx without fetching or storing chapter text.
|
||||
WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error
|
||||
|
||||
// ChapterExists returns true if the markdown object for ref already exists.
|
||||
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
|
||||
}
|
||||
|
||||
// BookReader is the read side used by the backend to serve content.
|
||||
type BookReader interface {
|
||||
// ReadMetadata returns the metadata for slug.
|
||||
// Returns (zero, false, nil) when not found.
|
||||
ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error)
|
||||
|
||||
// ListBooks returns all books sorted alphabetically by title.
|
||||
ListBooks(ctx context.Context) ([]domain.BookMeta, error)
|
||||
|
||||
// LocalSlugs returns the set of slugs that have metadata stored.
|
||||
LocalSlugs(ctx context.Context) (map[string]bool, error)
|
||||
|
||||
// MetadataMtime returns the Unix-second mtime of the metadata record, or 0.
|
||||
MetadataMtime(ctx context.Context, slug string) int64
|
||||
|
||||
// ReadChapter returns the raw markdown for chapter number n.
|
||||
ReadChapter(ctx context.Context, slug string, n int) (string, error)
|
||||
|
||||
// ListChapters returns all stored chapters for slug, sorted by number.
|
||||
ListChapters(ctx context.Context, slug string) ([]domain.ChapterInfo, error)
|
||||
|
||||
// CountChapters returns the count of stored chapters.
|
||||
CountChapters(ctx context.Context, slug string) int
|
||||
|
||||
// ReindexChapters rebuilds chapters_idx from MinIO objects for slug.
|
||||
ReindexChapters(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// RankingStore covers ranking reads and writes.
|
||||
type RankingStore interface {
|
||||
// WriteRankingItem upserts a single ranking entry (keyed on Slug).
|
||||
WriteRankingItem(ctx context.Context, item domain.RankingItem) error
|
||||
|
||||
// ReadRankingItems returns all ranking items sorted by rank ascending.
|
||||
ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error)
|
||||
|
||||
// RankingFreshEnough returns true when ranking rows exist and the most
|
||||
// recent Updated timestamp is within maxAge.
|
||||
RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
// AudioStore covers audio object storage (runner writes; backend reads).
|
||||
type AudioStore interface {
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
AudioObjectKey(slug string, n int, voice string) string
|
||||
|
||||
// AudioExists returns true when the audio object is present in MinIO.
|
||||
AudioExists(ctx context.Context, key string) bool
|
||||
|
||||
// PutAudio stores raw audio bytes under the given MinIO object key.
|
||||
PutAudio(ctx context.Context, key string, data []byte) error
|
||||
}
|
||||
|
||||
// PresignStore generates short-lived URLs — used exclusively by the backend.
|
||||
type PresignStore interface {
|
||||
// PresignChapter returns a presigned GET URL for a chapter markdown object.
|
||||
PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error)
|
||||
|
||||
// PresignAudio returns a presigned GET URL for an audio object.
|
||||
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
|
||||
|
||||
// PresignAvatarUpload returns a short-lived presigned PUT URL for uploading
|
||||
// an avatar image. ext should be "jpg", "png", or "webp".
|
||||
PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error)
|
||||
|
||||
// PresignAvatarURL returns a presigned GET URL for a user's avatar.
|
||||
// Returns ("", false, nil) when no avatar exists.
|
||||
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
|
||||
|
||||
// DeleteAvatar removes all avatar objects for a user.
|
||||
DeleteAvatar(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
// ProgressStore covers per-session reading progress — backend only.
|
||||
type ProgressStore interface {
|
||||
// GetProgress returns the reading progress for the given session + slug.
|
||||
GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool)
|
||||
|
||||
// SetProgress saves or updates reading progress.
|
||||
SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error
|
||||
|
||||
// AllProgress returns all progress entries for a session.
|
||||
AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error)
|
||||
|
||||
// DeleteProgress removes progress for a specific slug.
|
||||
DeleteProgress(ctx context.Context, sessionID, slug string) error
|
||||
}
|
||||
138
backend/internal/bookstore/bookstore_test.go
Normal file
138
backend/internal/bookstore/bookstore_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package bookstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ── Mock that satisfies all bookstore interfaces ──────────────────────────────
|
||||
|
||||
type mockStore struct{}
|
||||
|
||||
// BookWriter
|
||||
func (m *mockStore) WriteMetadata(_ context.Context, _ domain.BookMeta) error { return nil }
|
||||
func (m *mockStore) WriteChapter(_ context.Context, _ string, _ domain.Chapter) error { return nil }
|
||||
func (m *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) ChapterExists(_ context.Context, _ string, _ domain.ChapterRef) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// BookReader
|
||||
func (m *mockStore) ReadMetadata(_ context.Context, _ string) (domain.BookMeta, bool, error) {
|
||||
return domain.BookMeta{}, false, nil
|
||||
}
|
||||
func (m *mockStore) ListBooks(_ context.Context) ([]domain.BookMeta, error) { return nil, nil }
|
||||
func (m *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
func (m *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 }
|
||||
func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
// RankingStore
|
||||
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
||||
func (m *mockStore) ReadRankingItems(_ context.Context) ([]domain.RankingItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
|
||||
// PresignStore
|
||||
func (m *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (m *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (m *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
|
||||
return "", "", nil
|
||||
}
|
||||
func (m *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
func (m *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
|
||||
|
||||
// ProgressStore
|
||||
func (m *mockStore) GetProgress(_ context.Context, _, _ string) (domain.ReadingProgress, bool) {
|
||||
return domain.ReadingProgress{}, false
|
||||
}
|
||||
func (m *mockStore) SetProgress(_ context.Context, _ string, _ domain.ReadingProgress) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) AllProgress(_ context.Context, _ string) ([]domain.ReadingProgress, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
// ── Compile-time interface satisfaction ───────────────────────────────────────
|
||||
|
||||
var _ bookstore.BookWriter = (*mockStore)(nil)
|
||||
var _ bookstore.BookReader = (*mockStore)(nil)
|
||||
var _ bookstore.RankingStore = (*mockStore)(nil)
|
||||
var _ bookstore.AudioStore = (*mockStore)(nil)
|
||||
var _ bookstore.PresignStore = (*mockStore)(nil)
|
||||
var _ bookstore.ProgressStore = (*mockStore)(nil)
|
||||
|
||||
// ── Behavioural tests ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestBookWriter_WriteMetadata_ReturnsNilError(t *testing.T) {
|
||||
var w bookstore.BookWriter = &mockStore{}
|
||||
if err := w.WriteMetadata(context.Background(), domain.BookMeta{Slug: "test"}); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookReader_ReadMetadata_NotFound(t *testing.T) {
|
||||
var r bookstore.BookReader = &mockStore{}
|
||||
_, found, err := r.ReadMetadata(context.Background(), "unknown")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankingStore_RankingFreshEnough_ReturnsFalse(t *testing.T) {
|
||||
var s bookstore.RankingStore = &mockStore{}
|
||||
fresh, err := s.RankingFreshEnough(context.Background(), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fresh {
|
||||
t.Error("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioStore_AudioExists_ReturnsFalse(t *testing.T) {
|
||||
var s bookstore.AudioStore = &mockStore{}
|
||||
if s.AudioExists(context.Background(), "audio/slug/1/af_bella.mp3") {
|
||||
t.Error("expected false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressStore_GetProgress_NotFound(t *testing.T) {
|
||||
var s bookstore.ProgressStore = &mockStore{}
|
||||
_, found := s.GetProgress(context.Background(), "session-1", "slug")
|
||||
if found {
|
||||
t.Error("expected not found")
|
||||
}
|
||||
}
|
||||
206
backend/internal/browser/browser.go
Normal file
206
backend/internal/browser/browser.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Package browser provides a rate-limited HTTP client for web scraping.
|
||||
// The Client interface is the only thing the rest of the codebase depends on;
|
||||
// the concrete DirectClient can be swapped for any other implementation
|
||||
// (e.g. a Browserless-backed client) without touching callers.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrRateLimit is returned by GetContent when the server responds with 429.
|
||||
// It carries the suggested retry delay (from Retry-After header, or a default).
|
||||
var ErrRateLimit = errors.New("rate limited (429)")
|
||||
|
||||
// RateLimitError wraps ErrRateLimit and carries the suggested wait duration.
|
||||
type RateLimitError struct {
|
||||
// RetryAfter is how long the caller should wait before retrying.
|
||||
// Derived from the Retry-After response header when present; otherwise a default.
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *RateLimitError) Error() string {
|
||||
return fmt.Sprintf("rate limited (429): retry after %s", e.RetryAfter)
|
||||
}
|
||||
|
||||
func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimit }
|
||||
|
||||
// defaultRateLimitDelay is used when the server returns 429 with no Retry-After header.
|
||||
const defaultRateLimitDelay = 60 * time.Second
|
||||
|
||||
// Client is the interface used by scrapers to fetch raw page HTML.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Client interface {
|
||||
// GetContent fetches the URL and returns the full response body as a string.
|
||||
// It should respect the provided context for cancellation and timeouts.
|
||||
GetContent(ctx context.Context, pageURL string) (string, error)
|
||||
}
|
||||
|
||||
// Config holds tunable parameters for the direct HTTP client.
|
||||
type Config struct {
|
||||
// MaxConcurrent limits the number of simultaneous in-flight requests.
|
||||
// Defaults to 5 when 0.
|
||||
MaxConcurrent int
|
||||
// Timeout is the per-request deadline. Defaults to 90s when 0.
|
||||
Timeout time.Duration
|
||||
// ProxyURL is an optional outbound proxy, e.g. "http://user:pass@host:3128".
|
||||
// Falls back to HTTP_PROXY / HTTPS_PROXY environment variables when empty.
|
||||
ProxyURL string
|
||||
}
|
||||
|
||||
// DirectClient is a plain net/http-based Client with a concurrency semaphore.
|
||||
type DirectClient struct {
|
||||
http *http.Client
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
// NewDirectClient returns a DirectClient configured by cfg.
|
||||
func NewDirectClient(cfg Config) *DirectClient {
|
||||
if cfg.MaxConcurrent <= 0 {
|
||||
cfg.MaxConcurrent = 5
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 90 * time.Second
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConnsPerHost: cfg.MaxConcurrent * 2,
|
||||
DisableCompression: false,
|
||||
}
|
||||
if cfg.ProxyURL != "" {
|
||||
proxyParsed, err := url.Parse(cfg.ProxyURL)
|
||||
if err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyParsed)
|
||||
}
|
||||
} else {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
return &DirectClient{
|
||||
http: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: cfg.Timeout,
|
||||
},
|
||||
semaphore: make(chan struct{}, cfg.MaxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
// GetContent fetches pageURL respecting the concurrency limit.
|
||||
func (c *DirectClient) GetContent(ctx context.Context, pageURL string) (string, error) {
|
||||
// Acquire semaphore slot.
|
||||
select {
|
||||
case c.semaphore <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
defer func() { <-c.semaphore }()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browser: build request %s: %w", pageURL, err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browser: GET %s: %w", pageURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
delay := defaultRateLimitDelay
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
delay = time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
return "", &RateLimitError{RetryAfter: delay}
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("browser: GET %s returned %d", pageURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("browser: read body %s: %w", pageURL, err)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// Do implements httputil.Client so DirectClient can be passed to RetryGet.
|
||||
func (c *DirectClient) Do(req *http.Request) (*http.Response, error) {
|
||||
select {
|
||||
case c.semaphore <- struct{}{}:
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
defer func() { <-c.semaphore }()
|
||||
return c.http.Do(req)
|
||||
}
|
||||
|
||||
// ── Stub for testing ──────────────────────────────────────────────────────────
|
||||
|
||||
// StubClient is a test double for Client. It returns pre-configured responses
|
||||
// keyed on URL. Calls to unknown URLs return an error.
|
||||
type StubClient struct {
|
||||
mu sync.Mutex
|
||||
pages map[string]string
|
||||
errors map[string]error
|
||||
callLog []string
|
||||
}
|
||||
|
||||
// NewStub creates a StubClient with no pages pre-loaded.
|
||||
func NewStub() *StubClient {
|
||||
return &StubClient{
|
||||
pages: make(map[string]string),
|
||||
errors: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
// SetPage registers a URL → HTML body mapping.
|
||||
func (s *StubClient) SetPage(u, html string) {
|
||||
s.mu.Lock()
|
||||
s.pages[u] = html
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetError registers a URL → error mapping (returned instead of a body).
|
||||
func (s *StubClient) SetError(u string, err error) {
|
||||
s.mu.Lock()
|
||||
s.errors[u] = err
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// CallLog returns the ordered list of URLs that were requested.
|
||||
func (s *StubClient) CallLog() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, len(s.callLog))
|
||||
copy(out, s.callLog)
|
||||
return out
|
||||
}
|
||||
|
||||
// GetContent returns the registered page or an error for the URL.
|
||||
func (s *StubClient) GetContent(_ context.Context, pageURL string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.callLog = append(s.callLog, pageURL)
|
||||
if err, ok := s.errors[pageURL]; ok {
|
||||
return "", err
|
||||
}
|
||||
if html, ok := s.pages[pageURL]; ok {
|
||||
return html, nil
|
||||
}
|
||||
return "", fmt.Errorf("stub: no page registered for %q", pageURL)
|
||||
}
|
||||
141
backend/internal/browser/browser_test.go
Normal file
141
backend/internal/browser/browser_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package browser_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/browser"
|
||||
)
|
||||
|
||||
func TestDirectClient_GetContent_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("<html>hello</html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := browser.NewDirectClient(browser.Config{MaxConcurrent: 2, Timeout: 5 * time.Second})
|
||||
body, err := c.GetContent(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if body != "<html>hello</html>" {
|
||||
t.Errorf("want <html>hello</html>, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectClient_GetContent_4xxReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := browser.NewDirectClient(browser.Config{})
|
||||
_, err := c.GetContent(context.Background(), srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectClient_SemaphoreBlocksConcurrency(t *testing.T) {
|
||||
const maxConcurrent = 2
|
||||
var inflight atomic.Int32
|
||||
var peak atomic.Int32
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := inflight.Add(1)
|
||||
if int(n) > int(peak.Load()) {
|
||||
peak.Store(n)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
inflight.Add(-1)
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := browser.NewDirectClient(browser.Config{MaxConcurrent: maxConcurrent, Timeout: 5 * time.Second})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.GetContent(context.Background(), srv.URL)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if int(peak.Load()) > maxConcurrent {
|
||||
t.Errorf("concurrent requests exceeded limit: peak=%d, limit=%d", peak.Load(), maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectClient_ContextCancel(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel before making the request
|
||||
|
||||
c := browser.NewDirectClient(browser.Config{})
|
||||
_, err := c.GetContent(ctx, srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected context cancellation error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── StubClient ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestStubClient_ReturnsRegisteredPage(t *testing.T) {
|
||||
stub := browser.NewStub()
|
||||
stub.SetPage("http://example.com/page1", "<html>page1</html>")
|
||||
|
||||
body, err := stub.GetContent(context.Background(), "http://example.com/page1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if body != "<html>page1</html>" {
|
||||
t.Errorf("want page1 html, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubClient_ReturnsRegisteredError(t *testing.T) {
|
||||
stub := browser.NewStub()
|
||||
want := errors.New("network failure")
|
||||
stub.SetError("http://example.com/bad", want)
|
||||
|
||||
_, err := stub.GetContent(context.Background(), "http://example.com/bad")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubClient_UnknownURLReturnsError(t *testing.T) {
|
||||
stub := browser.NewStub()
|
||||
_, err := stub.GetContent(context.Background(), "http://unknown.example.com/")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubClient_CallLog(t *testing.T) {
|
||||
stub := browser.NewStub()
|
||||
stub.SetPage("http://example.com/a", "a")
|
||||
stub.SetPage("http://example.com/b", "b")
|
||||
|
||||
stub.GetContent(context.Background(), "http://example.com/a")
|
||||
stub.GetContent(context.Background(), "http://example.com/b")
|
||||
|
||||
log := stub.CallLog()
|
||||
if len(log) != 2 || log[0] != "http://example.com/a" || log[1] != "http://example.com/b" {
|
||||
t.Errorf("unexpected call log: %v", log)
|
||||
}
|
||||
}
|
||||
183
backend/internal/config/config.go
Normal file
183
backend/internal/config/config.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package config loads all service configuration from environment variables.
|
||||
// Both the runner and backend binaries call config.Load() at startup; each
|
||||
// uses only the sub-struct relevant to it.
|
||||
//
|
||||
// Every field has a documented default so the service starts sensibly without
|
||||
// any environment configuration (useful for local development).
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PocketBase holds connection settings for the remote PocketBase instance.
|
||||
type PocketBase struct {
|
||||
// URL is the base URL of the PocketBase instance, e.g. https://pb.libnovel.cc
|
||||
URL string
|
||||
// AdminEmail is the admin account email used for API authentication.
|
||||
AdminEmail string
|
||||
// AdminPassword is the admin account password.
|
||||
AdminPassword string
|
||||
}
|
||||
|
||||
// MinIO holds connection settings for the remote MinIO / S3-compatible store.
|
||||
type MinIO struct {
|
||||
// Endpoint is the host:port of the MinIO S3 API, e.g. storage.libnovel.cc:443
|
||||
Endpoint string
|
||||
// PublicEndpoint is the browser-visible endpoint used for presigned URLs.
|
||||
// Falls back to Endpoint when empty.
|
||||
PublicEndpoint string
|
||||
// AccessKey is the MinIO access key.
|
||||
AccessKey string
|
||||
// SecretKey is the MinIO secret key.
|
||||
SecretKey string
|
||||
// UseSSL enables TLS for the internal MinIO connection.
|
||||
UseSSL bool
|
||||
// PublicUseSSL enables TLS for presigned URL generation.
|
||||
PublicUseSSL bool
|
||||
// BucketChapters is the bucket that holds chapter markdown objects.
|
||||
BucketChapters string
|
||||
// BucketAudio is the bucket that holds generated audio MP3 objects.
|
||||
BucketAudio string
|
||||
// BucketAvatars is the bucket that holds user avatar images.
|
||||
BucketAvatars string
|
||||
}
|
||||
|
||||
// Kokoro holds connection settings for the Kokoro-FastAPI TTS service.
|
||||
type Kokoro struct {
|
||||
// URL is the base URL of the Kokoro service, e.g. https://kokoro.libnovel.cc
|
||||
// An empty string disables TTS generation.
|
||||
URL string
|
||||
// DefaultVoice is the voice used when none is specified.
|
||||
DefaultVoice string
|
||||
}
|
||||
|
||||
// HTTP holds settings for the HTTP server (backend only).
|
||||
type HTTP struct {
|
||||
// Addr is the listen address, e.g. ":8080"
|
||||
Addr string
|
||||
}
|
||||
|
||||
// Runner holds settings specific to the runner/worker binary.
|
||||
type Runner struct {
|
||||
// PollInterval is how often the runner checks PocketBase for pending tasks.
|
||||
PollInterval time.Duration
|
||||
// MaxConcurrentScrape limits simultaneous book-scrape goroutines.
|
||||
MaxConcurrentScrape int
|
||||
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
|
||||
MaxConcurrentAudio int
|
||||
// WorkerID is a unique identifier for this runner instance.
|
||||
// Defaults to the system hostname.
|
||||
WorkerID string
|
||||
// Workers is the number of chapter-scraping goroutines per book.
|
||||
Workers int
|
||||
// Timeout is the per-request HTTP timeout for scraping.
|
||||
Timeout time.Duration
|
||||
// ProxyURL is an optional outbound proxy for scraper HTTP requests.
|
||||
ProxyURL string
|
||||
}
|
||||
|
||||
// Config is the top-level configuration struct consumed by both binaries.
|
||||
type Config struct {
|
||||
PocketBase PocketBase
|
||||
MinIO MinIO
|
||||
Kokoro Kokoro
|
||||
HTTP HTTP
|
||||
Runner Runner
|
||||
// LogLevel is one of "debug", "info", "warn", "error".
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// Load reads all configuration from environment variables and returns a
|
||||
// populated Config. Missing variables fall back to documented defaults.
|
||||
func Load() Config {
|
||||
workerID, _ := os.Hostname()
|
||||
if workerID == "" {
|
||||
workerID = "runner-default"
|
||||
}
|
||||
|
||||
return Config{
|
||||
LogLevel: envOr("LOG_LEVEL", "info"),
|
||||
|
||||
PocketBase: PocketBase{
|
||||
URL: envOr("POCKETBASE_URL", "http://localhost:8090"),
|
||||
AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"),
|
||||
AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"),
|
||||
},
|
||||
|
||||
MinIO: MinIO{
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: envBool("MINIO_USE_SSL", false),
|
||||
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
||||
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
|
||||
},
|
||||
|
||||
Kokoro: Kokoro{
|
||||
URL: envOr("KOKORO_URL", ""),
|
||||
DefaultVoice: envOr("KOKORO_VOICE", "af_bella"),
|
||||
},
|
||||
|
||||
HTTP: HTTP{
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
},
|
||||
|
||||
Runner: Runner{
|
||||
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
|
||||
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
|
||||
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
|
||||
WorkerID: envOr("RUNNER_WORKER_ID", workerID),
|
||||
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
|
||||
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
|
||||
ProxyURL: envOr("SCRAPER_PROXY", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.ToLower(v) == "true"
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
127
backend/internal/config/config_test.go
Normal file
127
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
// Unset all relevant vars so we test pure defaults.
|
||||
unset := []string{
|
||||
"LOG_LEVEL",
|
||||
"POCKETBASE_URL", "POCKETBASE_ADMIN_EMAIL", "POCKETBASE_ADMIN_PASSWORD",
|
||||
"MINIO_ENDPOINT", "MINIO_PUBLIC_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY",
|
||||
"MINIO_USE_SSL", "MINIO_PUBLIC_USE_SSL",
|
||||
"MINIO_BUCKET_CHAPTERS", "MINIO_BUCKET_AUDIO", "MINIO_BUCKET_AVATARS",
|
||||
"KOKORO_URL", "KOKORO_VOICE",
|
||||
"BACKEND_HTTP_ADDR",
|
||||
"RUNNER_POLL_INTERVAL", "RUNNER_MAX_CONCURRENT_SCRAPE", "RUNNER_MAX_CONCURRENT_AUDIO",
|
||||
"RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT", "SCRAPER_PROXY",
|
||||
}
|
||||
for _, k := range unset {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
if cfg.LogLevel != "info" {
|
||||
t.Errorf("LogLevel: want info, got %q", cfg.LogLevel)
|
||||
}
|
||||
if cfg.PocketBase.URL != "http://localhost:8090" {
|
||||
t.Errorf("PocketBase.URL: want http://localhost:8090, got %q", cfg.PocketBase.URL)
|
||||
}
|
||||
if cfg.MinIO.BucketChapters != "libnovel-chapters" {
|
||||
t.Errorf("MinIO.BucketChapters: want libnovel-chapters, got %q", cfg.MinIO.BucketChapters)
|
||||
}
|
||||
if cfg.MinIO.UseSSL != false {
|
||||
t.Errorf("MinIO.UseSSL: want false, got %v", cfg.MinIO.UseSSL)
|
||||
}
|
||||
if cfg.MinIO.PublicUseSSL != true {
|
||||
t.Errorf("MinIO.PublicUseSSL: want true, got %v", cfg.MinIO.PublicUseSSL)
|
||||
}
|
||||
if cfg.Kokoro.DefaultVoice != "af_bella" {
|
||||
t.Errorf("Kokoro.DefaultVoice: want af_bella, got %q", cfg.Kokoro.DefaultVoice)
|
||||
}
|
||||
if cfg.HTTP.Addr != ":8080" {
|
||||
t.Errorf("HTTP.Addr: want :8080, got %q", cfg.HTTP.Addr)
|
||||
}
|
||||
if cfg.Runner.PollInterval != 30*time.Second {
|
||||
t.Errorf("Runner.PollInterval: want 30s, got %v", cfg.Runner.PollInterval)
|
||||
}
|
||||
if cfg.Runner.MaxConcurrentScrape != 1 {
|
||||
t.Errorf("Runner.MaxConcurrentScrape: want 1, got %d", cfg.Runner.MaxConcurrentScrape)
|
||||
}
|
||||
if cfg.Runner.MaxConcurrentAudio != 1 {
|
||||
t.Errorf("Runner.MaxConcurrentAudio: want 1, got %d", cfg.Runner.MaxConcurrentAudio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverride(t *testing.T) {
|
||||
t.Setenv("LOG_LEVEL", "debug")
|
||||
t.Setenv("POCKETBASE_URL", "https://pb.libnovel.cc")
|
||||
t.Setenv("MINIO_USE_SSL", "true")
|
||||
t.Setenv("MINIO_PUBLIC_USE_SSL", "false")
|
||||
t.Setenv("RUNNER_POLL_INTERVAL", "1m")
|
||||
t.Setenv("RUNNER_MAX_CONCURRENT_SCRAPE", "5")
|
||||
t.Setenv("RUNNER_WORKER_ID", "homelab-01")
|
||||
t.Setenv("BACKEND_HTTP_ADDR", ":9090")
|
||||
t.Setenv("KOKORO_URL", "https://kokoro.libnovel.cc")
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
if cfg.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel: want debug, got %q", cfg.LogLevel)
|
||||
}
|
||||
if cfg.PocketBase.URL != "https://pb.libnovel.cc" {
|
||||
t.Errorf("PocketBase.URL: want https://pb.libnovel.cc, got %q", cfg.PocketBase.URL)
|
||||
}
|
||||
if !cfg.MinIO.UseSSL {
|
||||
t.Error("MinIO.UseSSL: want true")
|
||||
}
|
||||
if cfg.MinIO.PublicUseSSL {
|
||||
t.Error("MinIO.PublicUseSSL: want false")
|
||||
}
|
||||
if cfg.Runner.PollInterval != time.Minute {
|
||||
t.Errorf("Runner.PollInterval: want 1m, got %v", cfg.Runner.PollInterval)
|
||||
}
|
||||
if cfg.Runner.MaxConcurrentScrape != 5 {
|
||||
t.Errorf("Runner.MaxConcurrentScrape: want 5, got %d", cfg.Runner.MaxConcurrentScrape)
|
||||
}
|
||||
if cfg.Runner.WorkerID != "homelab-01" {
|
||||
t.Errorf("Runner.WorkerID: want homelab-01, got %q", cfg.Runner.WorkerID)
|
||||
}
|
||||
if cfg.HTTP.Addr != ":9090" {
|
||||
t.Errorf("HTTP.Addr: want :9090, got %q", cfg.HTTP.Addr)
|
||||
}
|
||||
if cfg.Kokoro.URL != "https://kokoro.libnovel.cc" {
|
||||
t.Errorf("Kokoro.URL: want https://kokoro.libnovel.cc, got %q", cfg.Kokoro.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidInt_FallsToDefault(t *testing.T) {
|
||||
t.Setenv("RUNNER_MAX_CONCURRENT_SCRAPE", "notanumber")
|
||||
cfg := config.Load()
|
||||
if cfg.Runner.MaxConcurrentScrape != 1 {
|
||||
t.Errorf("want default 1, got %d", cfg.Runner.MaxConcurrentScrape)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidDuration_FallsToDefault(t *testing.T) {
|
||||
t.Setenv("RUNNER_POLL_INTERVAL", "notaduration")
|
||||
cfg := config.Load()
|
||||
if cfg.Runner.PollInterval != 30*time.Second {
|
||||
t.Errorf("want default 30s, got %v", cfg.Runner.PollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WorkerID_FallsToHostname(t *testing.T) {
|
||||
t.Setenv("RUNNER_WORKER_ID", "")
|
||||
cfg := config.Load()
|
||||
host, _ := os.Hostname()
|
||||
if host != "" && cfg.Runner.WorkerID != host {
|
||||
t.Errorf("want hostname %q, got %q", host, cfg.Runner.WorkerID)
|
||||
}
|
||||
}
|
||||
131
backend/internal/domain/domain.go
Normal file
131
backend/internal/domain/domain.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package domain contains the core value types shared across all packages
|
||||
// in this module. It has zero internal imports — only the standard library.
|
||||
// Every other package imports domain; domain imports nothing from this module.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ── Book types ────────────────────────────────────────────────────────────────
|
||||
|
||||
// BookMeta carries all bibliographic information about a novel.
|
||||
type BookMeta struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
TotalChapters int `json:"total_chapters,omitempty"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Ranking int `json:"ranking,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
|
||||
type CatalogueEntry struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ChapterRef is a reference to a single chapter returned by chapter-list pages.
|
||||
type ChapterRef struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Volume int `json:"volume,omitempty"`
|
||||
}
|
||||
|
||||
// Chapter contains the fully-extracted text of a single chapter.
|
||||
type Chapter struct {
|
||||
Ref ChapterRef `json:"ref"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// RankingItem represents a single entry in the novel ranking list.
|
||||
type RankingItem struct {
|
||||
Rank int `json:"rank"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
Updated time.Time `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
// ── Storage record types ──────────────────────────────────────────────────────
|
||||
|
||||
// ChapterInfo is a lightweight chapter descriptor stored in the index.
|
||||
type ChapterInfo struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Date string `json:"date,omitempty"`
|
||||
}
|
||||
|
||||
// ReadingProgress holds a single user's reading position for one book.
|
||||
type ReadingProgress struct {
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ── Task record types ─────────────────────────────────────────────────────────
|
||||
|
||||
// TaskStatus enumerates the lifecycle states of any task.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "pending"
|
||||
TaskStatusRunning TaskStatus = "running"
|
||||
TaskStatusDone TaskStatus = "done"
|
||||
TaskStatusFailed TaskStatus = "failed"
|
||||
TaskStatusCancelled TaskStatus = "cancelled"
|
||||
)
|
||||
|
||||
// ScrapeTask represents a book-scraping job stored in PocketBase.
|
||||
type ScrapeTask struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "catalogue" | "book" | "book_range"
|
||||
TargetURL string `json:"target_url"` // non-empty for single-book tasks
|
||||
FromChapter int `json:"from_chapter,omitempty"`
|
||||
ToChapter int `json:"to_chapter,omitempty"`
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
Status TaskStatus `json:"status"`
|
||||
BooksFound int `json:"books_found"`
|
||||
ChaptersScraped int `json:"chapters_scraped"`
|
||||
ChaptersSkipped int `json:"chapters_skipped"`
|
||||
Errors int `json:"errors"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
|
||||
type ScrapeResult struct {
|
||||
BooksFound int `json:"books_found"`
|
||||
ChaptersScraped int `json:"chapters_scraped"`
|
||||
ChaptersSkipped int `json:"chapters_skipped"`
|
||||
Errors int `json:"errors"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// AudioTask represents an audio-generation job stored in PocketBase.
|
||||
type AudioTask struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"` // "slug/chapter/voice"
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Voice string `json:"voice"`
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
Status TaskStatus `json:"status"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// AudioResult is the outcome reported by the runner after finishing an AudioTask.
|
||||
type AudioResult struct {
|
||||
ObjectKey string `json:"object_key,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
104
backend/internal/domain/domain_test.go
Normal file
104
backend/internal/domain/domain_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestBookMeta_JSONRoundtrip(t *testing.T) {
|
||||
orig := domain.BookMeta{
|
||||
Slug: "a-great-novel",
|
||||
Title: "A Great Novel",
|
||||
Author: "Jane Doe",
|
||||
Cover: "https://example.com/cover.jpg",
|
||||
Status: "Ongoing",
|
||||
Genres: []string{"Fantasy", "Action"},
|
||||
Summary: "A thrilling tale.",
|
||||
TotalChapters: 120,
|
||||
SourceURL: "https://novelfire.net/book/a-great-novel",
|
||||
Ranking: 3,
|
||||
}
|
||||
|
||||
b, err := json.Marshal(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got domain.BookMeta
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Slug != orig.Slug {
|
||||
t.Errorf("Slug: want %q, got %q", orig.Slug, got.Slug)
|
||||
}
|
||||
if got.TotalChapters != orig.TotalChapters {
|
||||
t.Errorf("TotalChapters: want %d, got %d", orig.TotalChapters, got.TotalChapters)
|
||||
}
|
||||
if len(got.Genres) != len(orig.Genres) {
|
||||
t.Errorf("Genres len: want %d, got %d", len(orig.Genres), len(got.Genres))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapterRef_JSONRoundtrip(t *testing.T) {
|
||||
orig := domain.ChapterRef{Number: 42, Title: "The Battle", URL: "https://example.com/ch-42", Volume: 2}
|
||||
b, _ := json.Marshal(orig)
|
||||
var got domain.ChapterRef
|
||||
json.Unmarshal(b, &got)
|
||||
if got != orig {
|
||||
t.Errorf("want %+v, got %+v", orig, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankingItem_JSONRoundtrip(t *testing.T) {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
orig := domain.RankingItem{
|
||||
Rank: 1,
|
||||
Slug: "top-novel",
|
||||
Title: "Top Novel",
|
||||
SourceURL: "https://novelfire.net/book/top-novel",
|
||||
Updated: now,
|
||||
}
|
||||
b, _ := json.Marshal(orig)
|
||||
var got domain.RankingItem
|
||||
json.Unmarshal(b, &got)
|
||||
if got.Rank != orig.Rank || got.Slug != orig.Slug {
|
||||
t.Errorf("want %+v, got %+v", orig, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeResult_JSONRoundtrip(t *testing.T) {
|
||||
orig := domain.ScrapeResult{BooksFound: 10, ChaptersScraped: 200, ChaptersSkipped: 5, Errors: 1, ErrorMessage: "one error"}
|
||||
b, _ := json.Marshal(orig)
|
||||
var got domain.ScrapeResult
|
||||
json.Unmarshal(b, &got)
|
||||
if got != orig {
|
||||
t.Errorf("want %+v, got %+v", orig, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioResult_JSONRoundtrip(t *testing.T) {
|
||||
orig := domain.AudioResult{ObjectKey: "audio/slug/1/af_bella.mp3"}
|
||||
b, _ := json.Marshal(orig)
|
||||
var got domain.AudioResult
|
||||
json.Unmarshal(b, &got)
|
||||
if got != orig {
|
||||
t.Errorf("want %+v, got %+v", orig, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatus_Values(t *testing.T) {
|
||||
cases := []domain.TaskStatus{
|
||||
domain.TaskStatusPending,
|
||||
domain.TaskStatusRunning,
|
||||
domain.TaskStatusDone,
|
||||
domain.TaskStatusFailed,
|
||||
domain.TaskStatusCancelled,
|
||||
}
|
||||
for _, s := range cases {
|
||||
if s == "" {
|
||||
t.Errorf("TaskStatus constant must not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
124
backend/internal/httputil/httputil.go
Normal file
124
backend/internal/httputil/httputil.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Package httputil provides shared HTTP helpers used by both the runner and
|
||||
// backend binaries. It has no imports from this module — only the standard
|
||||
// library — so it is safe to import from anywhere in the dependency graph.
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is the minimal interface for making HTTP GET requests.
|
||||
// *http.Client satisfies this interface.
|
||||
type Client interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// ErrMaxRetries is returned when RetryGet exhausts all attempts.
|
||||
var ErrMaxRetries = errors.New("httputil: max retries exceeded")
|
||||
|
||||
// errClientError is returned by doGet for 4xx responses; it signals that the
|
||||
// request should NOT be retried (the client is at fault).
|
||||
var errClientError = errors.New("httputil: client error")
|
||||
|
||||
// RetryGet fetches url using client, retrying on network errors or 5xx
|
||||
// responses with exponential backoff. It returns the full response body as a
|
||||
// string on success.
|
||||
//
|
||||
// - maxAttempts: total number of attempts (must be >= 1)
|
||||
// - baseDelay: initial wait before the second attempt; doubles each retry
|
||||
func RetryGet(ctx context.Context, client Client, url string, maxAttempts int, baseDelay time.Duration) (string, error) {
|
||||
if maxAttempts < 1 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
delay := baseDelay
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
delay *= 2
|
||||
}
|
||||
|
||||
body, err := doGet(ctx, client, url)
|
||||
if err == nil {
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
// Do not retry on context cancellation.
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
// Do not retry on 4xx — the client is at fault.
|
||||
if errors.Is(err, errClientError) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w after %d attempts: %w", ErrMaxRetries, maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
func doGet(ctx context.Context, client Client, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
return "", fmt.Errorf("GET %s: server error %d", url, resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("%w: GET %s: client error %d", errClientError, url, resp.StatusCode)
|
||||
}
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read body %s: %w", url, err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// WriteJSON writes v as JSON to w with the given HTTP status code and sets the
|
||||
// Content-Type header to application/json.
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// WriteError writes a JSON error object {"error": msg} with the given status.
|
||||
func WriteError(w http.ResponseWriter, status int, msg string) {
|
||||
WriteJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// maxBodyBytes is the limit applied by DecodeJSON to prevent unbounded reads.
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// DecodeJSON decodes a JSON request body into v. It enforces a 1 MiB size
|
||||
// limit and returns a descriptive error on any failure.
|
||||
func DecodeJSON(r *http.Request, v any) error {
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return fmt.Errorf("decode JSON body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
181
backend/internal/httputil/httputil_test.go
Normal file
181
backend/internal/httputil/httputil_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package httputil_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/httputil"
|
||||
)
|
||||
|
||||
// ── RetryGet ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRetryGet_ImmediateSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("hello"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if body != "hello" {
|
||||
t.Errorf("want hello, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryGet_RetriesOn5xx(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if body != "ok" {
|
||||
t.Errorf("want ok, got %q", body)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("want 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryGet_MaxAttemptsExceeded(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryGet_ContextCancelDuringBackoff(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Cancel after first failed attempt hits the backoff wait.
|
||||
go func() { time.Sleep(5 * time.Millisecond); cancel() }()
|
||||
|
||||
_, err := httputil.RetryGet(ctx, srv.Client(), srv.URL, 10, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected context cancellation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryGet_NoRetryOn4xx(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
// 4xx is NOT retried — should be exactly 1 call.
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call for 4xx, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── WriteJSON ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestWriteJSON_SetsHeadersAndStatus(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
httputil.WriteJSON(rr, http.StatusCreated, map[string]string{"key": "val"})
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Errorf("status: want 201, got %d", rr.Code)
|
||||
}
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type: want application/json, got %q", ct)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if got["key"] != "val" {
|
||||
t.Errorf("body key: want val, got %q", got["key"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── WriteError ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestWriteError_Format(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
httputil.WriteError(rr, http.StatusBadRequest, "bad input")
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status: want 400, got %d", rr.Code)
|
||||
}
|
||||
var got map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&got)
|
||||
if got["error"] != "bad input" {
|
||||
t.Errorf("error field: want bad input, got %q", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── DecodeJSON ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDecodeJSON_HappyPath(t *testing.T) {
|
||||
body := `{"name":"test","value":42}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
if err := httputil.DecodeJSON(req, &payload); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if payload.Name != "test" || payload.Value != 42 {
|
||||
t.Errorf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSON_UnknownFieldReturnsError(t *testing.T) {
|
||||
body := `{"name":"test","unknown_field":"boom"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := httputil.DecodeJSON(req, &payload); err == nil {
|
||||
t.Fatal("expected error for unknown field, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSON_BodyTooLarge(t *testing.T) {
|
||||
// Build a body > 1 MiB.
|
||||
big := bytes.Repeat([]byte("a"), 2<<20)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(big))
|
||||
|
||||
var payload map[string]any
|
||||
if err := httputil.DecodeJSON(req, &payload); err == nil {
|
||||
t.Fatal("expected error for oversized body, got nil")
|
||||
}
|
||||
}
|
||||
160
backend/internal/kokoro/client.go
Normal file
160
backend/internal/kokoro/client.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Package kokoro provides a client for the Kokoro-FastAPI TTS service.
|
||||
//
|
||||
// The Kokoro API is an OpenAI-compatible audio speech API that returns a
|
||||
// download link (X-Download-Path header) instead of streaming audio directly.
|
||||
// GenerateAudio handles the two-step flow: POST /v1/audio/speech → GET /v1/download/{file}.
|
||||
package kokoro
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is the interface for interacting with the Kokoro TTS service.
|
||||
type Client interface {
|
||||
// GenerateAudio synthesises text using voice and returns raw MP3 bytes.
|
||||
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
||||
|
||||
// ListVoices returns the available voice IDs. Falls back to an empty slice
|
||||
// on error — callers should treat an empty list as "service unavailable".
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// httpClient is the concrete Kokoro HTTP client.
|
||||
type httpClient struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Kokoro Client targeting baseURL (e.g. "https://kokoro.example.com").
|
||||
func New(baseURL string) Client {
|
||||
return &httpClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Minute},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAudio calls POST /v1/audio/speech (return_download_link=true) and then
|
||||
// downloads the resulting MP3 from GET /v1/download/{filename}.
|
||||
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("kokoro: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "af_bella"
|
||||
}
|
||||
|
||||
// ── Step 1: request generation ────────────────────────────────────────────
|
||||
reqBody, err := json.Marshal(map[string]any{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "mp3",
|
||||
"speed": 1.0,
|
||||
"stream": false,
|
||||
"return_download_link": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build speech request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: speech request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kokoro: speech returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
dlPath := resp.Header.Get("X-Download-Path")
|
||||
if dlPath == "" {
|
||||
return nil, fmt.Errorf("kokoro: no X-Download-Path header in response")
|
||||
}
|
||||
filename := dlPath
|
||||
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
|
||||
filename = dlPath[idx+1:]
|
||||
}
|
||||
if filename == "" {
|
||||
return nil, fmt.Errorf("kokoro: empty filename in X-Download-Path: %q", dlPath)
|
||||
}
|
||||
|
||||
// ── Step 2: download the generated file ───────────────────────────────────
|
||||
dlURL := c.baseURL + "/v1/download/" + filename
|
||||
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, dlURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build download request: %w", err)
|
||||
}
|
||||
|
||||
dlResp, err := c.http.Do(dlReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: download request: %w", err)
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
if dlResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kokoro: download returned %d", dlResp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(dlResp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: read download body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
|
||||
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
c.baseURL+"/v1/audio/voices", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build voices request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: voices request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("kokoro: voices returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Voices []string `json:"voices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("kokoro: decode voices response: %w", err)
|
||||
}
|
||||
return result.Voices, nil
|
||||
}
|
||||
|
||||
// VoiceSampleKey returns the MinIO object key for a voice sample MP3.
|
||||
// Key: _voice-samples/{voice}.mp3 (sanitised).
|
||||
func VoiceSampleKey(voice string) string {
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, voice)
|
||||
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
||||
}
|
||||
291
backend/internal/kokoro/client_test.go
Normal file
291
backend/internal/kokoro/client_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package kokoro_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
)
|
||||
|
||||
// ── VoiceSampleKey ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestVoiceSampleKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
voice string
|
||||
want string
|
||||
}{
|
||||
{"af_bella", "_voice-samples/af_bella.mp3"},
|
||||
{"am_echo", "_voice-samples/am_echo.mp3"},
|
||||
{"voice with spaces", "_voice-samples/voice_with_spaces.mp3"},
|
||||
{"special!@#chars", "_voice-samples/special___chars.mp3"},
|
||||
{"", "_voice-samples/.mp3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.voice, func(t *testing.T) {
|
||||
got := kokoro.VoiceSampleKey(tt.voice)
|
||||
if got != tt.want {
|
||||
t.Errorf("VoiceSampleKey(%q) = %q, want %q", tt.voice, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GenerateAudio ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGenerateAudio_EmptyText(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.GenerateAudio(context.Background(), "", "af_bella")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty text, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty text") {
|
||||
t.Errorf("expected 'empty text' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_DefaultVoice(t *testing.T) {
|
||||
// Tracks that the voice defaults to af_bella when empty.
|
||||
var capturedBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/speech" {
|
||||
buf := make([]byte, 512)
|
||||
n, _ := r.Body.Read(buf)
|
||||
capturedBody = string(buf[:n])
|
||||
w.Header().Set("X-Download-Path", "/download/test_file.mp3")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/download/") {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("fake-mp3-data"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
data, err := c.GenerateAudio(context.Background(), "hello world", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(data) != "fake-mp3-data" {
|
||||
t.Errorf("unexpected data: %q", string(data))
|
||||
}
|
||||
if !strings.Contains(capturedBody, `"af_bella"`) {
|
||||
t.Errorf("expected default voice af_bella in request body, got: %s", capturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_SpeechNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/speech" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.GenerateAudio(context.Background(), "text", "af_bella")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200 speech response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("expected 500 in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_NoDownloadPathHeader(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/speech" {
|
||||
// No X-Download-Path header
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.GenerateAudio(context.Background(), "text", "af_bella")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing X-Download-Path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "X-Download-Path") {
|
||||
t.Errorf("expected X-Download-Path in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_DownloadFails(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/speech" {
|
||||
w.Header().Set("X-Download-Path", "/v1/download/speech.mp3")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/download/") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.GenerateAudio(context.Background(), "text", "af_bella")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed download")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("expected 404 in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_FullPath(t *testing.T) {
|
||||
// X-Download-Path with a full path: extract just filename.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/speech" {
|
||||
w.Header().Set("X-Download-Path", "/some/nested/path/audio_abc123.mp3")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/download/audio_abc123.mp3" {
|
||||
_, _ = w.Write([]byte("audio-bytes"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
data, err := c.GenerateAudio(context.Background(), "text", "af_bella")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if string(data) != "audio-bytes" {
|
||||
t.Errorf("unexpected data: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_ContextCancelled(t *testing.T) {
|
||||
// Server that hangs — context should cancel before we get a response.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never respond.
|
||||
select {}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.GenerateAudio(ctx, "text", "af_bella")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListVoices ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListVoices_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/voices" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"voices":["af_bella","am_adam","bf_emma"]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
voices, err := c.ListVoices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(voices) != 3 {
|
||||
t.Errorf("expected 3 voices, got %d: %v", len(voices), voices)
|
||||
}
|
||||
if voices[0] != "af_bella" {
|
||||
t.Errorf("expected first voice to be af_bella, got %q", voices[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVoices_Non200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.ListVoices(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "503") {
|
||||
t.Errorf("expected 503 in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVoices_MalformedJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
_, err := c.ListVoices(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVoices_EmptyVoices(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"voices":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL)
|
||||
voices, err := c.ListVoices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(voices) != 0 {
|
||||
t.Errorf("expected 0 voices, got %d", len(voices))
|
||||
}
|
||||
}
|
||||
|
||||
// ── New ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestNew_TrailingSlashStripped(t *testing.T) {
|
||||
// Verify that a trailing slash on baseURL doesn't produce double-slash paths.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/audio/voices" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"voices":["af_bella"]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := kokoro.New(srv.URL + "/") // trailing slash
|
||||
voices, err := c.ListVoices(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(voices) == 0 {
|
||||
t.Error("expected at least one voice")
|
||||
}
|
||||
}
|
||||
228
backend/internal/novelfire/htmlutil/htmlutil.go
Normal file
228
backend/internal/novelfire/htmlutil/htmlutil.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package htmlutil provides helper functions for parsing HTML with
|
||||
// golang.org/x/net/html and extracting values by Selector descriptors.
|
||||
package htmlutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ResolveURL returns an absolute URL. If href is already absolute it is
|
||||
// returned unchanged. Otherwise it is resolved against base.
|
||||
func ResolveURL(base, href string) string {
|
||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||
return href
|
||||
}
|
||||
b, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base + href
|
||||
}
|
||||
ref, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return base + href
|
||||
}
|
||||
return b.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
// ParseHTML parses raw HTML and returns the root node.
|
||||
func ParseHTML(raw string) (*html.Node, error) {
|
||||
return html.Parse(strings.NewReader(raw))
|
||||
}
|
||||
|
||||
// selectorMatches reports whether node n matches sel.
|
||||
func selectorMatches(n *html.Node, sel scraper.Selector) bool {
|
||||
if n.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
if sel.Tag != "" && n.Data != sel.Tag {
|
||||
return false
|
||||
}
|
||||
if sel.ID != "" {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "id" && a.Val == sel.ID {
|
||||
goto checkClass
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
checkClass:
|
||||
if sel.Class != "" {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "class" {
|
||||
for _, cls := range strings.Fields(a.Val) {
|
||||
if cls == sel.Class {
|
||||
goto matched
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
matched:
|
||||
return true
|
||||
}
|
||||
|
||||
// AttrVal returns the value of attribute key from node n.
|
||||
func AttrVal(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TextContent returns the concatenated text content of all descendant text nodes.
|
||||
func TextContent(n *html.Node) string {
|
||||
var sb strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(cur *html.Node) {
|
||||
if cur.Type == html.TextNode {
|
||||
sb.WriteString(cur.Data)
|
||||
}
|
||||
for c := cur.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// FindFirst returns the first node matching sel within root.
|
||||
func FindFirst(root *html.Node, sel scraper.Selector) *html.Node {
|
||||
var found *html.Node
|
||||
var walk func(*html.Node) bool
|
||||
walk = func(n *html.Node) bool {
|
||||
if selectorMatches(n, sel) {
|
||||
found = n
|
||||
return true
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if walk(c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
walk(root)
|
||||
return found
|
||||
}
|
||||
|
||||
// FindAll returns all nodes matching sel within root.
|
||||
func FindAll(root *html.Node, sel scraper.Selector) []*html.Node {
|
||||
var results []*html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if selectorMatches(n, sel) {
|
||||
results = append(results, n)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return results
|
||||
}
|
||||
|
||||
// ExtractText extracts a string value from node n using sel.
|
||||
// If sel.Attr is set the attribute value is returned; otherwise the inner text.
|
||||
func ExtractText(n *html.Node, sel scraper.Selector) string {
|
||||
if sel.Attr != "" {
|
||||
return AttrVal(n, sel.Attr)
|
||||
}
|
||||
return TextContent(n)
|
||||
}
|
||||
|
||||
// ExtractFirst locates the first match in root and returns its text/attr value.
|
||||
func ExtractFirst(root *html.Node, sel scraper.Selector) string {
|
||||
n := FindFirst(root, sel)
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
return ExtractText(n, sel)
|
||||
}
|
||||
|
||||
// ExtractAll locates all matches in root and returns their text/attr values.
|
||||
func ExtractAll(root *html.Node, sel scraper.Selector) []string {
|
||||
nodes := FindAll(root, sel)
|
||||
out := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if v := ExtractText(n, sel); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NodeToMarkdown converts the children of an HTML node to a plain-text/Markdown
|
||||
// representation suitable for chapter storage.
|
||||
func NodeToMarkdown(n *html.Node) string {
|
||||
var sb strings.Builder
|
||||
nodeToMD(n, &sb)
|
||||
out := multiBlankLine.ReplaceAllString(sb.String(), "\n\n")
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
var multiBlankLine = regexp.MustCompile(`\n(\s*\n){2,}`)
|
||||
|
||||
var blockElements = map[string]bool{
|
||||
"p": true, "div": true, "br": true, "h1": true, "h2": true,
|
||||
"h3": true, "h4": true, "h5": true, "h6": true, "li": true,
|
||||
"blockquote": true, "pre": true, "hr": true,
|
||||
}
|
||||
|
||||
func nodeToMD(n *html.Node, sb *strings.Builder) {
|
||||
switch n.Type {
|
||||
case html.TextNode:
|
||||
sb.WriteString(n.Data)
|
||||
case html.ElementNode:
|
||||
tag := n.Data
|
||||
switch tag {
|
||||
case "br":
|
||||
sb.WriteString("\n")
|
||||
case "hr":
|
||||
sb.WriteString("\n---\n")
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||
level := int(tag[1] - '0')
|
||||
sb.WriteString("\n" + strings.Repeat("#", level) + " ")
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
nodeToMD(c, sb)
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
return
|
||||
case "p", "div", "blockquote":
|
||||
sb.WriteString("\n")
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
nodeToMD(c, sb)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
return
|
||||
case "em", "i":
|
||||
sb.WriteString("*")
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
nodeToMD(c, sb)
|
||||
}
|
||||
sb.WriteString("*")
|
||||
return
|
||||
case "strong", "b":
|
||||
sb.WriteString("**")
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
nodeToMD(c, sb)
|
||||
}
|
||||
sb.WriteString("**")
|
||||
return
|
||||
case "script", "style", "noscript":
|
||||
return // drop
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
nodeToMD(c, sb)
|
||||
}
|
||||
if blockElements[tag] {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
498
backend/internal/novelfire/scraper.go
Normal file
498
backend/internal/novelfire/scraper.go
Normal file
@@ -0,0 +1,498 @@
|
||||
// Package novelfire provides a NovelScraper implementation for novelfire.net.
|
||||
//
|
||||
// Site structure (as of 2025):
|
||||
//
|
||||
// Catalogue : https://novelfire.net/genre-all/sort-new/status-all/all-novel?page=N
|
||||
// Book page : https://novelfire.net/book/{slug}
|
||||
// Chapters : https://novelfire.net/book/{slug}/chapters?page=N
|
||||
// Chapter : https://novelfire.net/book/{slug}/{chapter-slug}
|
||||
package novelfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/browser"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/novelfire/htmlutil"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://novelfire.net"
|
||||
cataloguePath = "/genre-all/sort-new/status-all/all-novel"
|
||||
rankingPath = "/genre-all/sort-popular/status-all/all-novel"
|
||||
)
|
||||
|
||||
// Scraper is the novelfire.net implementation of scraper.NovelScraper.
|
||||
type Scraper struct {
|
||||
client browser.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ scraper.NovelScraper = (*Scraper)(nil)
|
||||
|
||||
// New returns a new novelfire Scraper backed by client.
|
||||
func New(client browser.Client, log *slog.Logger) *Scraper {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Scraper{client: client, log: log}
|
||||
}
|
||||
|
||||
// SourceName implements NovelScraper.
|
||||
func (s *Scraper) SourceName() string { return "novelfire.net" }
|
||||
|
||||
// ── CatalogueProvider ─────────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages.
|
||||
func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
|
||||
entries := make(chan domain.CatalogueEntry, 64)
|
||||
errs := make(chan error, 16)
|
||||
|
||||
go func() {
|
||||
defer close(entries)
|
||||
defer close(errs)
|
||||
|
||||
pageURL := baseURL + cataloguePath
|
||||
page := 1
|
||||
|
||||
for pageURL != "" {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
s.log.Info("scraping catalogue page", "page", page, "url", pageURL)
|
||||
raw, err := s.client.GetContent(ctx, pageURL)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("catalogue page %d: %w", page, err)
|
||||
return
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("catalogue page %d parse: %w", page, err)
|
||||
return
|
||||
}
|
||||
|
||||
cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true})
|
||||
if len(cards) == 0 {
|
||||
s.log.Warn("no novel cards found, stopping pagination", "page", page)
|
||||
return
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a", Attr: "href"})
|
||||
titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"})
|
||||
|
||||
var title, href string
|
||||
if linkNode != nil {
|
||||
href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"})
|
||||
}
|
||||
if titleNode != nil {
|
||||
title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{}))
|
||||
}
|
||||
if href == "" || title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
bookURL := resolveURL(baseURL, href)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case entries <- domain.CatalogueEntry{Title: title, URL: bookURL}:
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNextPageLink(root) {
|
||||
break
|
||||
}
|
||||
nextHref := ""
|
||||
for _, a := range htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) {
|
||||
if htmlutil.AttrVal(a, "rel") == "next" {
|
||||
nextHref = htmlutil.AttrVal(a, "href")
|
||||
break
|
||||
}
|
||||
}
|
||||
if nextHref == "" {
|
||||
break
|
||||
}
|
||||
pageURL = resolveURL(baseURL, nextHref)
|
||||
page++
|
||||
}
|
||||
}()
|
||||
|
||||
return entries, errs
|
||||
}
|
||||
|
||||
// ── MetadataProvider ──────────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeMetadata fetches and parses book metadata from the book's landing page.
|
||||
func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) {
|
||||
s.log.Debug("metadata fetch starting", "url", bookURL)
|
||||
|
||||
raw, err := s.client.GetContent(ctx, bookURL)
|
||||
if err != nil {
|
||||
return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err)
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
if err != nil {
|
||||
return domain.BookMeta{}, fmt.Errorf("metadata parse %s: %w", bookURL, err)
|
||||
}
|
||||
|
||||
title := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"})
|
||||
author := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "author"})
|
||||
|
||||
var cover string
|
||||
if fig := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); fig != nil {
|
||||
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"})
|
||||
if cover != "" && !strings.HasPrefix(cover, "http") {
|
||||
cover = baseURL + cover
|
||||
}
|
||||
}
|
||||
|
||||
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
|
||||
|
||||
genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
|
||||
var genres []string
|
||||
if genresNode != nil {
|
||||
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true})
|
||||
}
|
||||
|
||||
summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"})
|
||||
totalStr := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "chapter-count"})
|
||||
totalChapters := parseChapterCount(totalStr)
|
||||
|
||||
slug := slugFromURL(bookURL)
|
||||
|
||||
meta := domain.BookMeta{
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
Author: author,
|
||||
Cover: cover,
|
||||
Status: status,
|
||||
Genres: genres,
|
||||
Summary: summary,
|
||||
TotalChapters: totalChapters,
|
||||
SourceURL: bookURL,
|
||||
}
|
||||
s.log.Debug("metadata parsed", "slug", meta.Slug, "title", meta.Title)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ── ChapterListProvider ───────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeChapterList returns all chapter references for a book, ordered ascending.
|
||||
func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) {
|
||||
var refs []domain.ChapterRef
|
||||
baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters"
|
||||
page := 1
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return refs, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
|
||||
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
|
||||
|
||||
raw, err := s.client.GetContent(ctx, pageURL)
|
||||
if err != nil {
|
||||
return refs, fmt.Errorf("chapter list page %d: %w", page, err)
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
if err != nil {
|
||||
return refs, fmt.Errorf("chapter list page %d parse: %w", page, err)
|
||||
}
|
||||
|
||||
chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"})
|
||||
if chapterList == nil {
|
||||
s.log.Debug("chapter list container not found, stopping pagination", "page", page)
|
||||
break
|
||||
}
|
||||
|
||||
items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"})
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"})
|
||||
if linkNode == nil {
|
||||
continue
|
||||
}
|
||||
href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"})
|
||||
chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{})
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
chURL := resolveURL(baseURL, href)
|
||||
num := chapterNumberFromURL(chURL)
|
||||
if num <= 0 {
|
||||
num = len(refs) + 1
|
||||
s.log.Warn("chapter number not parseable from URL, falling back to position",
|
||||
"url", chURL, "position", num)
|
||||
}
|
||||
refs = append(refs, domain.ChapterRef{
|
||||
Number: num,
|
||||
Title: strings.TrimSpace(chTitle),
|
||||
URL: chURL,
|
||||
})
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// ── ChapterTextProvider ───────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeChapterText fetches and parses a single chapter page.
|
||||
func (s *Scraper) ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
|
||||
s.log.Debug("chapter text fetch starting", "chapter", ref.Number, "url", ref.URL)
|
||||
|
||||
raw, err := retryGet(ctx, s.log, s.client, ref.URL, 9, 6*time.Second)
|
||||
if err != nil {
|
||||
return domain.Chapter{}, fmt.Errorf("chapter %d fetch: %w", ref.Number, err)
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
if err != nil {
|
||||
return domain.Chapter{}, fmt.Errorf("chapter %d parse: %w", ref.Number, err)
|
||||
}
|
||||
|
||||
container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"})
|
||||
if container == nil {
|
||||
return domain.Chapter{}, fmt.Errorf("chapter %d: #content container not found in %s", ref.Number, ref.URL)
|
||||
}
|
||||
|
||||
text := htmlutil.NodeToMarkdown(container)
|
||||
|
||||
s.log.Debug("chapter text parsed", "chapter", ref.Number, "text_bytes", len(text))
|
||||
|
||||
return domain.Chapter{Ref: ref, Text: text}, nil
|
||||
}
|
||||
|
||||
// ── RankingProvider ───────────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeRanking pages through up to maxPages pages of the popular-novels listing.
|
||||
// maxPages <= 0 means all pages. The caller decides whether to persist items.
|
||||
func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) {
|
||||
entries := make(chan domain.BookMeta, 32)
|
||||
errs := make(chan error, 16)
|
||||
|
||||
go func() {
|
||||
defer close(entries)
|
||||
defer close(errs)
|
||||
|
||||
rank := 1
|
||||
|
||||
for page := 1; maxPages <= 0 || page <= maxPages; page++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page)
|
||||
s.log.Info("scraping popular ranking page", "page", page, "url", pageURL)
|
||||
|
||||
raw, err := s.client.GetContent(ctx, pageURL)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("ranking page %d: %w", page, err)
|
||||
return
|
||||
}
|
||||
|
||||
root, err := htmlutil.ParseHTML(raw)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("ranking page %d parse: %w", page, err)
|
||||
return
|
||||
}
|
||||
|
||||
cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true})
|
||||
if len(cards) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, card := range cards {
|
||||
linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a"})
|
||||
if linkNode == nil {
|
||||
continue
|
||||
}
|
||||
href := htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"})
|
||||
bookURL := resolveURL(baseURL, href)
|
||||
if bookURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(htmlutil.ExtractFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}))
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "title"}))
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var cover string
|
||||
if fig := htmlutil.FindFirst(card, scraper.Selector{Tag: "figure", Class: "novel-cover"}); fig != nil {
|
||||
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "data-src"})
|
||||
if cover == "" {
|
||||
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"})
|
||||
}
|
||||
if strings.HasPrefix(cover, "data:") {
|
||||
cover = ""
|
||||
}
|
||||
if cover != "" && !strings.HasPrefix(cover, "http") {
|
||||
cover = baseURL + cover
|
||||
}
|
||||
}
|
||||
|
||||
meta := domain.BookMeta{
|
||||
Slug: slugFromURL(bookURL),
|
||||
Title: title,
|
||||
Cover: cover,
|
||||
SourceURL: bookURL,
|
||||
Ranking: rank,
|
||||
}
|
||||
rank++
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case entries <- meta:
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNextPageLink(root) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return entries, errs
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) }
|
||||
|
||||
func hasNextPageLink(root *html.Node) bool {
|
||||
links := htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true})
|
||||
for _, a := range links {
|
||||
for _, attr := range a.Attr {
|
||||
if attr.Key == "rel" && attr.Val == "next" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func slugFromURL(bookURL string) string {
|
||||
u, err := url.Parse(bookURL)
|
||||
if err != nil {
|
||||
return bookURL
|
||||
}
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) >= 2 && parts[0] == "book" {
|
||||
return parts[1]
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseChapterCount(s string) int {
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(fields[0])
|
||||
return n
|
||||
}
|
||||
|
||||
func chapterNumberFromURL(chapterURL string) int {
|
||||
u, err := url.Parse(chapterURL)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
seg := path.Base(u.Path)
|
||||
seg = strings.TrimPrefix(seg, "chapter-")
|
||||
seg = strings.TrimPrefix(seg, "chap-")
|
||||
seg = strings.TrimPrefix(seg, "ch-")
|
||||
digits := strings.FieldsFunc(seg, func(r rune) bool {
|
||||
return r < '0' || r > '9'
|
||||
})
|
||||
if len(digits) == 0 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(digits[0])
|
||||
return n
|
||||
}
|
||||
|
||||
// retryGet calls client.GetContent up to maxAttempts times with exponential backoff.
|
||||
// If the server returns 429 (ErrRateLimit), the suggested Retry-After delay is used
|
||||
// instead of the geometric backoff delay.
|
||||
func retryGet(
|
||||
ctx context.Context,
|
||||
log *slog.Logger,
|
||||
client browser.Client,
|
||||
pageURL string,
|
||||
maxAttempts int,
|
||||
baseDelay time.Duration,
|
||||
) (string, error) {
|
||||
var lastErr error
|
||||
delay := baseDelay
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
raw, err := client.GetContent(ctx, pageURL)
|
||||
if err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return "", err
|
||||
}
|
||||
if attempt < maxAttempts {
|
||||
// If the server is rate-limiting us, honour its Retry-After delay.
|
||||
waitFor := delay
|
||||
var rlErr *browser.RateLimitError
|
||||
if errors.As(err, &rlErr) {
|
||||
waitFor = rlErr.RetryAfter
|
||||
if log != nil {
|
||||
log.Warn("rate limited, backing off",
|
||||
"url", pageURL, "attempt", attempt, "retry_in", waitFor)
|
||||
}
|
||||
} else {
|
||||
if log != nil {
|
||||
log.Warn("fetch failed, retrying",
|
||||
"url", pageURL, "attempt", attempt, "retry_in", delay, "err", err)
|
||||
}
|
||||
delay *= 2
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(waitFor):
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
129
backend/internal/novelfire/scraper_test.go
Normal file
129
backend/internal/novelfire/scraper_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package novelfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSlugFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"https://novelfire.net/book/shadow-slave", "shadow-slave"},
|
||||
{"https://novelfire.net/book/a-dragon-against-the-whole-world", "a-dragon-against-the-whole-world"},
|
||||
{"https://novelfire.net/book/foo/chapter-1", "foo"},
|
||||
{"https://novelfire.net/", ""},
|
||||
{"not-a-url", "not-a-url"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := slugFromURL(c.url)
|
||||
if got != c.want {
|
||||
t.Errorf("slugFromURL(%q) = %q, want %q", c.url, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapterNumberFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
want int
|
||||
}{
|
||||
{"https://novelfire.net/book/shadow-slave/chapter-42", 42},
|
||||
{"https://novelfire.net/book/shadow-slave/chapter-1000", 1000},
|
||||
{"https://novelfire.net/book/shadow-slave/chap-7", 7},
|
||||
{"https://novelfire.net/book/shadow-slave/ch-3", 3},
|
||||
{"https://novelfire.net/book/shadow-slave/42", 42},
|
||||
{"https://novelfire.net/book/shadow-slave/no-number-here", 0},
|
||||
{"not-a-url", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := chapterNumberFromURL(c.url)
|
||||
if got != c.want {
|
||||
t.Errorf("chapterNumberFromURL(%q) = %d, want %d", c.url, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChapterCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"123 Chapters", 123},
|
||||
{"1,234 Chapters", 1234},
|
||||
{"0", 0},
|
||||
{"", 0},
|
||||
{"500", 500},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := parseChapterCount(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("parseChapterCount(%q) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryGet_ContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
stub := newStubClient()
|
||||
stub.setError("https://example.com/page", context.Canceled)
|
||||
|
||||
_, err := retryGet(ctx, nil, stub, "https://example.com/page", 3, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryGet_EventualSuccess(t *testing.T) {
|
||||
stub := newStubClient()
|
||||
calls := 0
|
||||
stub.setFn("https://example.com/page", func() (string, error) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
return "<html>ok</html>", nil
|
||||
})
|
||||
|
||||
got, err := retryGet(context.Background(), nil, stub, "https://example.com/page", 5, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "<html>ok</html>" {
|
||||
t.Errorf("got %q, want html", got)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("expected 3 calls, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── minimal stub client for tests ─────────────────────────────────────────────
|
||||
|
||||
type stubClient struct {
|
||||
errors map[string]error
|
||||
fns map[string]func() (string, error)
|
||||
}
|
||||
|
||||
func newStubClient() *stubClient {
|
||||
return &stubClient{
|
||||
errors: make(map[string]error),
|
||||
fns: make(map[string]func() (string, error)),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubClient) setError(u string, err error) { s.errors[u] = err }
|
||||
|
||||
func (s *stubClient) setFn(u string, fn func() (string, error)) { s.fns[u] = fn }
|
||||
|
||||
func (s *stubClient) GetContent(_ context.Context, pageURL string) (string, error) {
|
||||
if fn, ok := s.fns[pageURL]; ok {
|
||||
return fn()
|
||||
}
|
||||
if err, ok := s.errors[pageURL]; ok {
|
||||
return "", err
|
||||
}
|
||||
return "", context.DeadlineExceeded
|
||||
}
|
||||
205
backend/internal/orchestrator/orchestrator.go
Normal file
205
backend/internal/orchestrator/orchestrator.go
Normal file
@@ -0,0 +1,205 @@
|
||||
// Package orchestrator coordinates metadata extraction, chapter-list fetching,
|
||||
// and parallel chapter scraping for a single book.
|
||||
//
|
||||
// Design:
|
||||
// - RunBook scrapes one book (metadata + chapter list + chapter texts) end-to-end.
|
||||
// - N worker goroutines pull chapter refs from a shared queue and call ScrapeChapterText.
|
||||
// - The caller (runner poll loop) owns the outer task-claim / finish cycle.
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
)
|
||||
|
||||
// Config holds tunable parameters for the orchestrator.
|
||||
type Config struct {
|
||||
// Workers is the number of goroutines used to scrape chapters in parallel.
|
||||
// Defaults to runtime.NumCPU() when 0.
|
||||
Workers int
|
||||
}
|
||||
|
||||
// Orchestrator runs a single-book scrape pipeline.
|
||||
type Orchestrator struct {
|
||||
novel scraper.NovelScraper
|
||||
store bookstore.BookWriter
|
||||
log *slog.Logger
|
||||
workers int
|
||||
}
|
||||
|
||||
// New returns a new Orchestrator.
|
||||
func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log *slog.Logger) *Orchestrator {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
workers := cfg.Workers
|
||||
if workers <= 0 {
|
||||
workers = runtime.NumCPU()
|
||||
}
|
||||
return &Orchestrator{novel: novel, store: store, log: log, workers: workers}
|
||||
}
|
||||
|
||||
// RunBook scrapes a single book described by task. It handles:
|
||||
// 1. Metadata scrape + write
|
||||
// 2. Chapter list scrape + write
|
||||
// 3. Parallel chapter text scrape + write (worker pool)
|
||||
//
|
||||
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
|
||||
// if the run failed at the metadata or chapter-list level.
|
||||
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
|
||||
o.log.Info("orchestrator: RunBook starting",
|
||||
"task_id", task.ID,
|
||||
"kind", task.Kind,
|
||||
"url", task.TargetURL,
|
||||
"workers", o.workers,
|
||||
)
|
||||
|
||||
var result domain.ScrapeResult
|
||||
|
||||
if task.TargetURL == "" {
|
||||
result.ErrorMessage = "task has no target URL"
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Step 1: Metadata ──────────────────────────────────────────────────────
|
||||
meta, err := o.novel.ScrapeMetadata(ctx, task.TargetURL)
|
||||
if err != nil {
|
||||
o.log.Error("metadata scrape failed", "url", task.TargetURL, "err", err)
|
||||
result.ErrorMessage = fmt.Sprintf("metadata: %v", err)
|
||||
result.Errors++
|
||||
return result
|
||||
}
|
||||
|
||||
if err := o.store.WriteMetadata(ctx, meta); err != nil {
|
||||
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
|
||||
// non-fatal: continue to chapters
|
||||
result.Errors++
|
||||
} else {
|
||||
result.BooksFound = 1
|
||||
}
|
||||
|
||||
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
||||
|
||||
// ── Step 2: Chapter list ──────────────────────────────────────────────────
|
||||
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL)
|
||||
if err != nil {
|
||||
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
|
||||
result.ErrorMessage = fmt.Sprintf("chapter list: %v", err)
|
||||
result.Errors++
|
||||
return result
|
||||
}
|
||||
|
||||
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
|
||||
|
||||
// Persist chapter refs (without text) so the index exists early.
|
||||
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {
|
||||
o.log.Warn("chapter refs write failed", "slug", meta.Slug, "err", wErr)
|
||||
}
|
||||
|
||||
// ── Step 3: Chapter texts (worker pool) ───────────────────────────────────
|
||||
type chapterJob struct {
|
||||
slug string
|
||||
ref domain.ChapterRef
|
||||
total int // total chapters to scrape (for progress logging)
|
||||
}
|
||||
work := make(chan chapterJob, o.workers*4)
|
||||
|
||||
var scraped, skipped, errors atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < o.workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for job := range work {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if o.store.ChapterExists(ctx, job.slug, job.ref) {
|
||||
o.log.Debug("chapter already exists, skipping",
|
||||
"slug", job.slug, "chapter", job.ref.Number)
|
||||
skipped.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
ch, err := o.novel.ScrapeChapterText(ctx, job.ref)
|
||||
if err != nil {
|
||||
o.log.Error("chapter scrape failed",
|
||||
"slug", job.slug, "chapter", job.ref.Number, "err", err)
|
||||
errors.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := o.store.WriteChapter(ctx, job.slug, ch); err != nil {
|
||||
o.log.Error("chapter write failed",
|
||||
"slug", job.slug, "chapter", job.ref.Number, "err", err)
|
||||
errors.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
n := scraped.Add(1)
|
||||
// Log a progress summary every 25 chapters scraped.
|
||||
if n%25 == 0 {
|
||||
o.log.Info("scraping chapters",
|
||||
"slug", job.slug, "scraped", n, "total", job.total)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Count how many chapters will actually be enqueued (for progress logging).
|
||||
toScrape := 0
|
||||
for _, ref := range refs {
|
||||
if task.FromChapter > 0 && ref.Number < task.FromChapter {
|
||||
continue
|
||||
}
|
||||
if task.ToChapter > 0 && ref.Number > task.ToChapter {
|
||||
continue
|
||||
}
|
||||
toScrape++
|
||||
}
|
||||
|
||||
// Enqueue chapter jobs respecting the optional range filter from the task.
|
||||
for _, ref := range refs {
|
||||
if task.FromChapter > 0 && ref.Number < task.FromChapter {
|
||||
skipped.Add(1)
|
||||
continue
|
||||
}
|
||||
if task.ToChapter > 0 && ref.Number > task.ToChapter {
|
||||
skipped.Add(1)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
goto drain
|
||||
case work <- chapterJob{slug: meta.Slug, ref: ref, total: toScrape}:
|
||||
}
|
||||
}
|
||||
|
||||
drain:
|
||||
close(work)
|
||||
wg.Wait()
|
||||
|
||||
result.ChaptersScraped = int(scraped.Load())
|
||||
result.ChaptersSkipped = int(skipped.Load())
|
||||
result.Errors += int(errors.Load())
|
||||
|
||||
o.log.Info("book scrape finished",
|
||||
"slug", meta.Slug,
|
||||
"scraped", result.ChaptersScraped,
|
||||
"skipped", result.ChaptersSkipped,
|
||||
"errors", result.Errors,
|
||||
)
|
||||
return result
|
||||
}
|
||||
210
backend/internal/orchestrator/orchestrator_test.go
Normal file
210
backend/internal/orchestrator/orchestrator_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ── stubs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type stubScraper struct {
|
||||
meta domain.BookMeta
|
||||
metaErr error
|
||||
refs []domain.ChapterRef
|
||||
refsErr error
|
||||
chapters map[int]domain.Chapter
|
||||
chapErr map[int]error
|
||||
}
|
||||
|
||||
func (s *stubScraper) SourceName() string { return "stub" }
|
||||
|
||||
func (s *stubScraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
|
||||
ch := make(chan domain.CatalogueEntry)
|
||||
errs := make(chan error)
|
||||
close(ch)
|
||||
close(errs)
|
||||
return ch, errs
|
||||
}
|
||||
|
||||
func (s *stubScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) {
|
||||
return s.meta, s.metaErr
|
||||
}
|
||||
|
||||
func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) {
|
||||
return s.refs, s.refsErr
|
||||
}
|
||||
|
||||
func (s *stubScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
|
||||
if s.chapErr != nil {
|
||||
if err, ok := s.chapErr[ref.Number]; ok {
|
||||
return domain.Chapter{}, err
|
||||
}
|
||||
}
|
||||
if s.chapters != nil {
|
||||
if ch, ok := s.chapters[ref.Number]; ok {
|
||||
return ch, nil
|
||||
}
|
||||
}
|
||||
return domain.Chapter{Ref: ref, Text: "text"}, nil
|
||||
}
|
||||
|
||||
func (s *stubScraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) {
|
||||
ch := make(chan domain.BookMeta)
|
||||
errs := make(chan error)
|
||||
close(ch)
|
||||
close(errs)
|
||||
return ch, errs
|
||||
}
|
||||
|
||||
type stubStore struct {
|
||||
mu sync.Mutex
|
||||
metaWritten []domain.BookMeta
|
||||
chaptersWritten []domain.Chapter
|
||||
existing map[string]bool // "slug:N" → exists
|
||||
writeMetaErr error
|
||||
}
|
||||
|
||||
func (s *stubStore) WriteMetadata(_ context.Context, meta domain.BookMeta) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.writeMetaErr != nil {
|
||||
return s.writeMetaErr
|
||||
}
|
||||
s.metaWritten = append(s.metaWritten, meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) WriteChapter(_ context.Context, slug string, ch domain.Chapter) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.chaptersWritten = append(s.chaptersWritten, ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := slug + ":" + string(rune('0'+ref.Number))
|
||||
return s.existing[key]
|
||||
}
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRunBook_HappyPath(t *testing.T) {
|
||||
sc := &stubScraper{
|
||||
meta: domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"},
|
||||
refs: []domain.ChapterRef{
|
||||
{Number: 1, Title: "Ch 1", URL: "https://example.com/book/test-book/chapter-1"},
|
||||
{Number: 2, Title: "Ch 2", URL: "https://example.com/book/test-book/chapter-2"},
|
||||
{Number: 3, Title: "Ch 3", URL: "https://example.com/book/test-book/chapter-3"},
|
||||
},
|
||||
}
|
||||
st := &stubStore{}
|
||||
o := New(Config{Workers: 2}, sc, st, nil)
|
||||
|
||||
task := domain.ScrapeTask{
|
||||
ID: "t1",
|
||||
Kind: "book",
|
||||
TargetURL: "https://example.com/book/test-book",
|
||||
}
|
||||
|
||||
result := o.RunBook(context.Background(), task)
|
||||
|
||||
if result.ErrorMessage != "" {
|
||||
t.Fatalf("unexpected error: %s", result.ErrorMessage)
|
||||
}
|
||||
if result.BooksFound != 1 {
|
||||
t.Errorf("BooksFound = %d, want 1", result.BooksFound)
|
||||
}
|
||||
if result.ChaptersScraped != 3 {
|
||||
t.Errorf("ChaptersScraped = %d, want 3", result.ChaptersScraped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBook_MetadataError(t *testing.T) {
|
||||
sc := &stubScraper{metaErr: errors.New("404 not found")}
|
||||
st := &stubStore{}
|
||||
o := New(Config{Workers: 1}, sc, st, nil)
|
||||
|
||||
result := o.RunBook(context.Background(), domain.ScrapeTask{
|
||||
ID: "t2",
|
||||
TargetURL: "https://example.com/book/missing",
|
||||
})
|
||||
|
||||
if result.ErrorMessage == "" {
|
||||
t.Fatal("expected ErrorMessage to be set")
|
||||
}
|
||||
if result.Errors != 1 {
|
||||
t.Errorf("Errors = %d, want 1", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBook_ChapterRange(t *testing.T) {
|
||||
sc := &stubScraper{
|
||||
meta: domain.BookMeta{Slug: "range-book", SourceURL: "https://example.com/book/range-book"},
|
||||
refs: func() []domain.ChapterRef {
|
||||
var refs []domain.ChapterRef
|
||||
for i := 1; i <= 10; i++ {
|
||||
refs = append(refs, domain.ChapterRef{Number: i, URL: "https://example.com/book/range-book/chapter-" + string(rune('0'+i))})
|
||||
}
|
||||
return refs
|
||||
}(),
|
||||
}
|
||||
st := &stubStore{}
|
||||
o := New(Config{Workers: 2}, sc, st, nil)
|
||||
|
||||
result := o.RunBook(context.Background(), domain.ScrapeTask{
|
||||
ID: "t3",
|
||||
TargetURL: "https://example.com/book/range-book",
|
||||
FromChapter: 3,
|
||||
ToChapter: 7,
|
||||
})
|
||||
|
||||
if result.ErrorMessage != "" {
|
||||
t.Fatalf("unexpected error: %s", result.ErrorMessage)
|
||||
}
|
||||
// chapters 3–7 = 5 scraped, chapters 1-2 and 8-10 = 5 skipped
|
||||
if result.ChaptersScraped != 5 {
|
||||
t.Errorf("ChaptersScraped = %d, want 5", result.ChaptersScraped)
|
||||
}
|
||||
if result.ChaptersSkipped != 5 {
|
||||
t.Errorf("ChaptersSkipped = %d, want 5", result.ChaptersSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBook_ContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
sc := &stubScraper{
|
||||
meta: domain.BookMeta{Slug: "ctx-book", SourceURL: "https://example.com/book/ctx-book"},
|
||||
refs: []domain.ChapterRef{
|
||||
{Number: 1, URL: "https://example.com/book/ctx-book/chapter-1"},
|
||||
},
|
||||
}
|
||||
st := &stubStore{}
|
||||
o := New(Config{Workers: 1}, sc, st, nil)
|
||||
|
||||
// Should not panic; result may have errors or zero chapters.
|
||||
result := o.RunBook(ctx, domain.ScrapeTask{
|
||||
ID: "t4",
|
||||
TargetURL: "https://example.com/book/ctx-book",
|
||||
})
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestRunBook_EmptyTargetURL(t *testing.T) {
|
||||
o := New(Config{Workers: 1}, &stubScraper{}, &stubStore{}, nil)
|
||||
result := o.RunBook(context.Background(), domain.ScrapeTask{ID: "t5"})
|
||||
if result.ErrorMessage == "" {
|
||||
t.Fatal("expected ErrorMessage for empty target URL")
|
||||
}
|
||||
}
|
||||
21
backend/internal/runner/helpers.go
Normal file
21
backend/internal/runner/helpers.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// stripMarkdown removes common markdown syntax from src, returning plain text
|
||||
// suitable for TTS. Mirrors the helper in the scraper's server package.
|
||||
func stripMarkdown(src string) string {
|
||||
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
|
||||
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
|
||||
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
386
backend/internal/runner/runner.go
Normal file
386
backend/internal/runner/runner.go
Normal file
@@ -0,0 +1,386 @@
|
||||
// Package runner implements the worker loop that polls PocketBase for pending
|
||||
// scrape and audio tasks, executes them, and reports results back.
|
||||
//
|
||||
// Design:
|
||||
// - Run(ctx) loops on a ticker; each tick claims and dispatches pending tasks.
|
||||
// - Scrape tasks are dispatched to the Orchestrator (one goroutine per task,
|
||||
// up to MaxConcurrentScrape).
|
||||
// - Audio tasks fetch chapter text, call Kokoro, upload to MinIO, and report
|
||||
// the result back (up to MaxConcurrentAudio goroutines).
|
||||
// - The runner is stateless between ticks; all state lives in PocketBase.
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/orchestrator"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
// Config tunes the runner behaviour.
|
||||
type Config struct {
|
||||
// WorkerID uniquely identifies this runner instance in PocketBase records.
|
||||
WorkerID string
|
||||
// PollInterval is how often the runner checks for new tasks.
|
||||
PollInterval time.Duration
|
||||
// MaxConcurrentScrape limits simultaneous book-scrape goroutines.
|
||||
MaxConcurrentScrape int
|
||||
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
|
||||
MaxConcurrentAudio int
|
||||
// OrchestratorWorkers is the chapter-scraping parallelism inside each book run.
|
||||
OrchestratorWorkers int
|
||||
// HeartbeatInterval is how often active tasks PATCH their heartbeat_at
|
||||
// timestamp to signal they are still alive. Defaults to 30s when 0.
|
||||
HeartbeatInterval time.Duration
|
||||
// StaleTaskThreshold is how old a heartbeat must be (or absent) before the
|
||||
// task is considered orphaned and reset to pending. Defaults to 2m when 0.
|
||||
StaleTaskThreshold time.Duration
|
||||
}
|
||||
|
||||
// Dependencies are the external services the runner depends on.
|
||||
type Dependencies struct {
|
||||
// Consumer claims tasks from PocketBase.
|
||||
Consumer taskqueue.Consumer
|
||||
// BookWriter persists scraped data (used by orchestrator).
|
||||
BookWriter bookstore.BookWriter
|
||||
// BookReader reads chapter text for audio generation.
|
||||
BookReader bookstore.BookReader
|
||||
// AudioStore persists generated audio and checks key existence.
|
||||
AudioStore bookstore.AudioStore
|
||||
// Novel is the scraper implementation.
|
||||
Novel scraper.NovelScraper
|
||||
// Kokoro is the TTS client.
|
||||
Kokoro kokoro.Client
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Runner is the main worker process.
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
// New creates a Runner from cfg and deps.
|
||||
// Any zero/nil field in deps will cause a panic at construction time to fail fast.
|
||||
func New(cfg Config, deps Dependencies) *Runner {
|
||||
if cfg.PollInterval <= 0 {
|
||||
cfg.PollInterval = 30 * time.Second
|
||||
}
|
||||
if cfg.MaxConcurrentScrape <= 0 {
|
||||
cfg.MaxConcurrentScrape = 2
|
||||
}
|
||||
if cfg.MaxConcurrentAudio <= 0 {
|
||||
cfg.MaxConcurrentAudio = 1
|
||||
}
|
||||
if cfg.WorkerID == "" {
|
||||
cfg.WorkerID = "runner"
|
||||
}
|
||||
if cfg.HeartbeatInterval <= 0 {
|
||||
cfg.HeartbeatInterval = 30 * time.Second
|
||||
}
|
||||
if cfg.StaleTaskThreshold <= 0 {
|
||||
cfg.StaleTaskThreshold = 2 * time.Minute
|
||||
}
|
||||
if deps.Log == nil {
|
||||
deps.Log = slog.Default()
|
||||
}
|
||||
return &Runner{cfg: cfg, deps: deps}
|
||||
}
|
||||
|
||||
// livenessFile is the path written on every successful poll so that the Docker
|
||||
// healthcheck (CMD /healthcheck file /tmp/runner.alive <max_age>) can verify
|
||||
// the runner is still making progress.
|
||||
const livenessFile = "/tmp/runner.alive"
|
||||
|
||||
// touchAlive writes the current UTC time to livenessFile. Errors are logged but
|
||||
// never fatal — liveness is best-effort and should not crash the runner.
|
||||
func (r *Runner) touchAlive() {
|
||||
data := []byte(time.Now().UTC().Format(time.RFC3339))
|
||||
if err := os.WriteFile(livenessFile, data, 0o644); err != nil {
|
||||
r.deps.Log.Warn("runner: failed to write liveness file", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the poll loop, blocking until ctx is cancelled.
|
||||
// On each tick it claims and executes all available pending tasks.
|
||||
// Scrape and audio tasks run in separate goroutine pools bounded by
|
||||
// MaxConcurrentScrape and MaxConcurrentAudio respectively.
|
||||
func (r *Runner) Run(ctx context.Context) error {
|
||||
r.deps.Log.Info("runner: starting",
|
||||
"worker_id", r.cfg.WorkerID,
|
||||
"poll_interval", r.cfg.PollInterval,
|
||||
"max_scrape", r.cfg.MaxConcurrentScrape,
|
||||
"max_audio", r.cfg.MaxConcurrentAudio,
|
||||
)
|
||||
|
||||
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
|
||||
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Write liveness file immediately so the first healthcheck passes before
|
||||
// the first poll completes.
|
||||
r.touchAlive()
|
||||
|
||||
tick := time.NewTicker(r.cfg.PollInterval)
|
||||
defer tick.Stop()
|
||||
|
||||
// Run one poll immediately on startup, then on each tick.
|
||||
for {
|
||||
r.poll(ctx, scrapeSem, audioSem, &wg)
|
||||
r.touchAlive()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.deps.Log.Info("runner: context cancelled, draining active tasks")
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
r.deps.Log.Info("runner: all tasks drained, exiting")
|
||||
case <-time.After(2 * time.Minute):
|
||||
r.deps.Log.Warn("runner: drain timeout exceeded, forcing exit")
|
||||
}
|
||||
return nil
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll claims all available pending tasks and dispatches them to goroutines.
|
||||
// It claims tasks in a tight loop until no more are available.
|
||||
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) {
|
||||
// ── Reap orphaned tasks ───────────────────────────────────────────────
|
||||
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
|
||||
r.deps.Log.Warn("runner: reap stale tasks failed", "err", err)
|
||||
} else if n > 0 {
|
||||
r.deps.Log.Info("runner: reaped stale tasks", "count", n)
|
||||
}
|
||||
|
||||
// ── Scrape tasks ──────────────────────────────────────────────────────
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
task, ok, err := r.deps.Consumer.ClaimNextScrapeTask(ctx, r.cfg.WorkerID)
|
||||
if err != nil {
|
||||
r.deps.Log.Error("runner: ClaimNextScrapeTask failed", "err", err)
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
break // queue empty
|
||||
}
|
||||
// Acquire semaphore (non-blocking when full — leave task running).
|
||||
select {
|
||||
case scrapeSem <- struct{}{}:
|
||||
default:
|
||||
// Too many concurrent scrapes — the task stays claimed but we can't
|
||||
// run it right now. Log and break; the next poll will pick it up if
|
||||
// still running (it won't be re-claimed while status=running).
|
||||
r.deps.Log.Warn("runner: scrape semaphore full, will retry next tick",
|
||||
"task_id", task.ID)
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(t domain.ScrapeTask) {
|
||||
defer wg.Done()
|
||||
defer func() { <-scrapeSem }()
|
||||
r.runScrapeTask(ctx, t)
|
||||
}(task)
|
||||
}
|
||||
|
||||
// ── Audio tasks ───────────────────────────────────────────────────────
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
task, ok, err := r.deps.Consumer.ClaimNextAudioTask(ctx, r.cfg.WorkerID)
|
||||
if err != nil {
|
||||
r.deps.Log.Error("runner: ClaimNextAudioTask failed", "err", err)
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
break // queue empty
|
||||
}
|
||||
select {
|
||||
case audioSem <- struct{}{}:
|
||||
default:
|
||||
r.deps.Log.Warn("runner: audio semaphore full, will retry next tick",
|
||||
"task_id", task.ID)
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(t domain.AudioTask) {
|
||||
defer wg.Done()
|
||||
defer func() { <-audioSem }()
|
||||
r.runAudioTask(ctx, t)
|
||||
}(task)
|
||||
}
|
||||
}
|
||||
|
||||
// runScrapeTask executes one scrape task end-to-end and reports the result.
|
||||
func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL)
|
||||
log.Info("runner: scrape task starting")
|
||||
|
||||
// Heartbeat goroutine: periodically PATCH heartbeat_at so the reaper knows
|
||||
// this task is still alive. Cancelled when the task finishes.
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
go func() {
|
||||
tick := time.NewTicker(r.cfg.HeartbeatInterval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-hbCtx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
|
||||
log.Warn("runner: heartbeat failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
oCfg := orchestrator.Config{Workers: r.cfg.OrchestratorWorkers}
|
||||
o := orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log)
|
||||
|
||||
var result domain.ScrapeResult
|
||||
|
||||
switch task.Kind {
|
||||
case "catalogue":
|
||||
result = r.runCatalogueTask(ctx, task, o, log)
|
||||
case "book", "book_range":
|
||||
result = o.RunBook(ctx, task)
|
||||
default:
|
||||
result.ErrorMessage = fmt.Sprintf("unknown task kind: %q", task.Kind)
|
||||
log.Warn("runner: unknown task kind")
|
||||
}
|
||||
|
||||
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishScrapeTask failed", "err", err)
|
||||
}
|
||||
log.Info("runner: scrape task finished",
|
||||
"scraped", result.ChaptersScraped,
|
||||
"skipped", result.ChaptersSkipped,
|
||||
"errors", result.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
// runCatalogueTask runs a full catalogue scrape by iterating catalogue entries
|
||||
// and running a book task for each one.
|
||||
func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o *orchestrator.Orchestrator, log *slog.Logger) domain.ScrapeResult {
|
||||
entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx)
|
||||
var result domain.ScrapeResult
|
||||
|
||||
for entry := range entries {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
bookTask := domain.ScrapeTask{
|
||||
ID: task.ID,
|
||||
Kind: "book",
|
||||
TargetURL: entry.URL,
|
||||
}
|
||||
bookResult := o.RunBook(ctx, bookTask)
|
||||
result.BooksFound += bookResult.BooksFound + 1
|
||||
result.ChaptersScraped += bookResult.ChaptersScraped
|
||||
result.ChaptersSkipped += bookResult.ChaptersSkipped
|
||||
result.Errors += bookResult.Errors
|
||||
}
|
||||
|
||||
if err := <-errCh; err != nil {
|
||||
log.Warn("runner: catalogue scrape finished with error", "err", err)
|
||||
result.Errors++
|
||||
if result.ErrorMessage == "" {
|
||||
result.ErrorMessage = err.Error()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// runAudioTask executes one audio-generation task:
|
||||
// 1. Read chapter text from MinIO.
|
||||
// 2. Call Kokoro to generate audio.
|
||||
// 3. Upload MP3 to MinIO under the standard audio object key.
|
||||
// 4. Report result back to PocketBase.
|
||||
func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice)
|
||||
log.Info("runner: audio task starting")
|
||||
|
||||
// Heartbeat goroutine: periodically PATCH heartbeat_at so the reaper knows
|
||||
// this task is still alive. Cancelled when the task finishes.
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
go func() {
|
||||
tick := time.NewTicker(r.cfg.HeartbeatInterval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-hbCtx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
|
||||
log.Warn("runner: heartbeat failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fail := func(msg string) {
|
||||
log.Error("runner: audio task failed", "reason", msg)
|
||||
result := domain.AudioResult{ErrorMessage: msg}
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: read chapter text.
|
||||
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("read chapter: %v", err))
|
||||
return
|
||||
}
|
||||
text := stripMarkdown(raw)
|
||||
if text == "" {
|
||||
fail("chapter text is empty after stripping markdown")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: generate audio.
|
||||
if r.deps.Kokoro == nil {
|
||||
fail("kokoro client not configured")
|
||||
return
|
||||
}
|
||||
audioData, err := r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("kokoro generate: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: upload to MinIO.
|
||||
key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice)
|
||||
if err := r.deps.AudioStore.PutAudio(ctx, key, audioData); err != nil {
|
||||
fail(fmt.Sprintf("put audio: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: report success.
|
||||
result := domain.AudioResult{ObjectKey: key}
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
}
|
||||
log.Info("runner: audio task finished", "key", key)
|
||||
}
|
||||
365
backend/internal/runner/runner_test.go
Normal file
365
backend/internal/runner/runner_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package runner_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/runner"
|
||||
)
|
||||
|
||||
// ── Stub types ────────────────────────────────────────────────────────────────
|
||||
|
||||
// stubConsumer is a test double for taskqueue.Consumer.
|
||||
type stubConsumer struct {
|
||||
scrapeQueue []domain.ScrapeTask
|
||||
audioQueue []domain.AudioTask
|
||||
scrapeIdx int
|
||||
audioIdx int
|
||||
finished []string
|
||||
failCalled []string
|
||||
claimErr error
|
||||
}
|
||||
|
||||
func (s *stubConsumer) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
if s.claimErr != nil {
|
||||
return domain.ScrapeTask{}, false, s.claimErr
|
||||
}
|
||||
if s.scrapeIdx >= len(s.scrapeQueue) {
|
||||
return domain.ScrapeTask{}, false, nil
|
||||
}
|
||||
t := s.scrapeQueue[s.scrapeIdx]
|
||||
s.scrapeIdx++
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
||||
if s.claimErr != nil {
|
||||
return domain.AudioTask{}, false, s.claimErr
|
||||
}
|
||||
if s.audioIdx >= len(s.audioQueue) {
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
t := s.audioQueue[s.audioIdx]
|
||||
s.audioIdx++
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
|
||||
s.finished = append(s.finished, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FinishAudioTask(_ context.Context, id string, _ domain.AudioResult) error {
|
||||
s.finished = append(s.finished, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
|
||||
s.failCalled = append(s.failCalled, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (s *stubConsumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stubBookWriter satisfies bookstore.BookWriter (no-op).
|
||||
type stubBookWriter struct{}
|
||||
|
||||
func (s *stubBookWriter) WriteMetadata(_ context.Context, _ domain.BookMeta) error { return nil }
|
||||
func (s *stubBookWriter) WriteChapter(_ context.Context, _ string, _ domain.Chapter) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubBookWriter) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.ChapterRef) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
||||
type stubBookReader struct {
|
||||
text string
|
||||
readErr error
|
||||
}
|
||||
|
||||
func (s *stubBookReader) ReadChapter(_ context.Context, _ string, _ int) (string, error) {
|
||||
return s.text, s.readErr
|
||||
}
|
||||
func (s *stubBookReader) ReadMetadata(_ context.Context, _ string) (domain.BookMeta, bool, error) {
|
||||
return domain.BookMeta{}, false, nil
|
||||
}
|
||||
func (s *stubBookReader) ListBooks(_ context.Context) ([]domain.BookMeta, error) { return nil, nil }
|
||||
func (s *stubBookReader) LocalSlugs(_ context.Context) (map[string]bool, error) { return nil, nil }
|
||||
func (s *stubBookReader) MetadataMtime(_ context.Context, _ string) int64 { return 0 }
|
||||
func (s *stubBookReader) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubBookReader) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||
func (s *stubBookReader) ReindexChapters(_ context.Context, _ string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stubAudioStore satisfies bookstore.AudioStore.
|
||||
type stubAudioStore struct {
|
||||
putCalled atomic.Int32
|
||||
putErr error
|
||||
}
|
||||
|
||||
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
|
||||
}
|
||||
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||
s.putCalled.Add(1)
|
||||
return s.putErr
|
||||
}
|
||||
|
||||
// stubNovelScraper satisfies scraper.NovelScraper minimally.
|
||||
type stubNovelScraper struct {
|
||||
entries []domain.CatalogueEntry
|
||||
metaErr error
|
||||
chapters []domain.ChapterRef
|
||||
}
|
||||
|
||||
func (s *stubNovelScraper) ScrapeCatalogue(_ context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
|
||||
ch := make(chan domain.CatalogueEntry, len(s.entries))
|
||||
errCh := make(chan error, 1)
|
||||
for _, e := range s.entries {
|
||||
ch <- e
|
||||
}
|
||||
close(ch)
|
||||
close(errCh)
|
||||
return ch, errCh
|
||||
}
|
||||
|
||||
func (s *stubNovelScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) {
|
||||
if s.metaErr != nil {
|
||||
return domain.BookMeta{}, s.metaErr
|
||||
}
|
||||
return domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"}, nil
|
||||
}
|
||||
|
||||
func (s *stubNovelScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) {
|
||||
return s.chapters, nil
|
||||
}
|
||||
|
||||
func (s *stubNovelScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
|
||||
return domain.Chapter{Ref: ref, Text: "# Chapter\n\nSome text."}, nil
|
||||
}
|
||||
|
||||
func (s *stubNovelScraper) ScrapeRanking(_ context.Context, _ int) (<-chan domain.BookMeta, <-chan error) {
|
||||
ch := make(chan domain.BookMeta)
|
||||
errCh := make(chan error, 1)
|
||||
close(ch)
|
||||
close(errCh)
|
||||
return ch, errCh
|
||||
}
|
||||
|
||||
func (s *stubNovelScraper) SourceName() string { return "stub" }
|
||||
|
||||
// stubKokoro satisfies kokoro.Client.
|
||||
type stubKokoro struct {
|
||||
data []byte
|
||||
genErr error
|
||||
called atomic.Int32
|
||||
}
|
||||
|
||||
func (s *stubKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) {
|
||||
s.called.Add(1)
|
||||
return s.data, s.genErr
|
||||
}
|
||||
|
||||
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return []string{"af_bella"}, nil
|
||||
}
|
||||
|
||||
// ── stripMarkdown helper ──────────────────────────────────────────────────────
|
||||
|
||||
func TestStripMarkdownViaAudioTask(t *testing.T) {
|
||||
// Verify markdown is stripped before sending to Kokoro.
|
||||
// We inject chapter text with markdown; the kokoro stub verifies data flows.
|
||||
consumer := &stubConsumer{
|
||||
audioQueue: []domain.AudioTask{
|
||||
{ID: "a1", Slug: "book", Chapter: 1, Voice: "af_bella", Status: domain.TaskStatusRunning},
|
||||
},
|
||||
}
|
||||
bookReader := &stubBookReader{text: "## Chapter 1\n\nPlain **text** here."}
|
||||
audioStore := &stubAudioStore{}
|
||||
kokoroStub := &stubKokoro{data: []byte("mp3")}
|
||||
|
||||
cfg := runner.Config{
|
||||
WorkerID: "test",
|
||||
PollInterval: time.Hour, // long poll — we'll cancel manually
|
||||
}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: &stubBookWriter{},
|
||||
BookReader: bookReader,
|
||||
AudioStore: audioStore,
|
||||
Novel: &stubNovelScraper{},
|
||||
Kokoro: kokoroStub,
|
||||
}
|
||||
|
||||
r := runner.New(cfg, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = r.Run(ctx)
|
||||
|
||||
if kokoroStub.called.Load() != 1 {
|
||||
t.Errorf("expected Kokoro.GenerateAudio called once, got %d", kokoroStub.called.Load())
|
||||
}
|
||||
if audioStore.putCalled.Load() != 1 {
|
||||
t.Errorf("expected PutAudio called once, got %d", audioStore.putCalled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioTask_ReadChapterError(t *testing.T) {
|
||||
consumer := &stubConsumer{
|
||||
audioQueue: []domain.AudioTask{
|
||||
{ID: "a2", Slug: "book", Chapter: 2, Voice: "af_bella", Status: domain.TaskStatusRunning},
|
||||
},
|
||||
}
|
||||
bookReader := &stubBookReader{readErr: errors.New("chapter not found")}
|
||||
audioStore := &stubAudioStore{}
|
||||
kokoroStub := &stubKokoro{data: []byte("mp3")}
|
||||
|
||||
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: &stubBookWriter{},
|
||||
BookReader: bookReader,
|
||||
AudioStore: audioStore,
|
||||
Novel: &stubNovelScraper{},
|
||||
Kokoro: kokoroStub,
|
||||
}
|
||||
|
||||
r := runner.New(cfg, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = r.Run(ctx)
|
||||
|
||||
// Kokoro should not be called; FinishAudioTask should be called with error.
|
||||
if kokoroStub.called.Load() != 0 {
|
||||
t.Errorf("expected Kokoro not called, got %d", kokoroStub.called.Load())
|
||||
}
|
||||
if len(consumer.finished) != 1 {
|
||||
t.Errorf("expected FinishAudioTask called once, got %d", len(consumer.finished))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioTask_KokoroError(t *testing.T) {
|
||||
consumer := &stubConsumer{
|
||||
audioQueue: []domain.AudioTask{
|
||||
{ID: "a3", Slug: "book", Chapter: 3, Voice: "af_bella", Status: domain.TaskStatusRunning},
|
||||
},
|
||||
}
|
||||
bookReader := &stubBookReader{text: "Chapter text."}
|
||||
audioStore := &stubAudioStore{}
|
||||
kokoroStub := &stubKokoro{genErr: errors.New("tts failed")}
|
||||
|
||||
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: &stubBookWriter{},
|
||||
BookReader: bookReader,
|
||||
AudioStore: audioStore,
|
||||
Novel: &stubNovelScraper{},
|
||||
Kokoro: kokoroStub,
|
||||
}
|
||||
|
||||
r := runner.New(cfg, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = r.Run(ctx)
|
||||
|
||||
if audioStore.putCalled.Load() != 0 {
|
||||
t.Errorf("expected PutAudio not called, got %d", audioStore.putCalled.Load())
|
||||
}
|
||||
if len(consumer.finished) != 1 {
|
||||
t.Errorf("expected FinishAudioTask called once, got %d", len(consumer.finished))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeTask_BookKind(t *testing.T) {
|
||||
consumer := &stubConsumer{
|
||||
scrapeQueue: []domain.ScrapeTask{
|
||||
{ID: "s1", Kind: "book", TargetURL: "https://example.com/book/test-book", Status: domain.TaskStatusRunning},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: &stubBookWriter{},
|
||||
BookReader: &stubBookReader{},
|
||||
AudioStore: &stubAudioStore{},
|
||||
Novel: &stubNovelScraper{},
|
||||
Kokoro: &stubKokoro{},
|
||||
}
|
||||
|
||||
r := runner.New(cfg, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = r.Run(ctx)
|
||||
|
||||
if len(consumer.finished) != 1 || consumer.finished[0] != "s1" {
|
||||
t.Errorf("expected task s1 finished, got %v", consumer.finished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeTask_UnknownKind(t *testing.T) {
|
||||
consumer := &stubConsumer{
|
||||
scrapeQueue: []domain.ScrapeTask{
|
||||
{ID: "s2", Kind: "unknown_kind", Status: domain.TaskStatusRunning},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: &stubBookWriter{},
|
||||
BookReader: &stubBookReader{},
|
||||
AudioStore: &stubAudioStore{},
|
||||
Novel: &stubNovelScraper{},
|
||||
Kokoro: &stubKokoro{},
|
||||
}
|
||||
|
||||
r := runner.New(cfg, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = r.Run(ctx)
|
||||
|
||||
// Unknown kind still finishes the task (with error message in result).
|
||||
if len(consumer.finished) != 1 || consumer.finished[0] != "s2" {
|
||||
t.Errorf("expected task s2 finished, got %v", consumer.finished)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_CancelImmediately(t *testing.T) {
|
||||
consumer := &stubConsumer{}
|
||||
cfg := runner.Config{WorkerID: "test", PollInterval: 10 * time.Millisecond}
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: &stubBookWriter{},
|
||||
BookReader: &stubBookReader{},
|
||||
AudioStore: &stubAudioStore{},
|
||||
Novel: &stubNovelScraper{},
|
||||
Kokoro: &stubKokoro{},
|
||||
}
|
||||
|
||||
r := runner.New(cfg, deps)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel before Run
|
||||
|
||||
err := r.Run(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil on graceful shutdown, got %v", err)
|
||||
}
|
||||
}
|
||||
58
backend/internal/scraper/scraper.go
Normal file
58
backend/internal/scraper/scraper.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package scraper defines the NovelScraper interface and its sub-interfaces.
|
||||
// Domain types live in internal/domain — this package only defines the scraping
|
||||
// contract so that novelfire and any future scrapers can be swapped freely.
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// CatalogueProvider can enumerate every novel available on a source site.
|
||||
type CatalogueProvider interface {
|
||||
ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error)
|
||||
}
|
||||
|
||||
// MetadataProvider can extract structured book metadata from a novel's landing page.
|
||||
type MetadataProvider interface {
|
||||
ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error)
|
||||
}
|
||||
|
||||
// ChapterListProvider can enumerate all chapters of a book.
|
||||
type ChapterListProvider interface {
|
||||
ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error)
|
||||
}
|
||||
|
||||
// ChapterTextProvider can extract the readable text from a single chapter page.
|
||||
type ChapterTextProvider interface {
|
||||
ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error)
|
||||
}
|
||||
|
||||
// RankingProvider can enumerate novels from a ranking page.
|
||||
type RankingProvider interface {
|
||||
// ScrapeRanking pages through up to maxPages ranking pages.
|
||||
// maxPages <= 0 means all pages.
|
||||
ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error)
|
||||
}
|
||||
|
||||
// NovelScraper is the full interface a concrete novel source must implement.
|
||||
type NovelScraper interface {
|
||||
CatalogueProvider
|
||||
MetadataProvider
|
||||
ChapterListProvider
|
||||
ChapterTextProvider
|
||||
RankingProvider
|
||||
|
||||
// SourceName returns the human-readable name of this scraper, e.g. "novelfire.net".
|
||||
SourceName() string
|
||||
}
|
||||
|
||||
// Selector describes how to locate an element in an HTML document.
|
||||
type Selector struct {
|
||||
Tag string
|
||||
Class string
|
||||
ID string
|
||||
Attr string
|
||||
Multiple bool
|
||||
}
|
||||
194
backend/internal/storage/minio.go
Normal file
194
backend/internal/storage/minio.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
)
|
||||
|
||||
// minioClient wraps the official minio-go client with bucket names.
|
||||
type minioClient struct {
|
||||
client *minio.Client // internal — all read/write operations
|
||||
pubClient *minio.Client // presign-only — initialised against the public endpoint
|
||||
bucketChapters string
|
||||
bucketAudio string
|
||||
bucketAvatars string
|
||||
}
|
||||
|
||||
func newMinioClient(cfg config.MinIO) (*minioClient, error) {
|
||||
creds := credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, "")
|
||||
|
||||
internal, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: creds,
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio: init internal client: %w", err)
|
||||
}
|
||||
|
||||
// Presigned URLs must be signed with the hostname the browser will use
|
||||
// (PUBLIC_MINIO_PUBLIC_URL), because AWS Signature V4 includes the Host
|
||||
// header in the canonical request — a URL signed against "minio:9000" will
|
||||
// return SignatureDoesNotMatch when the browser fetches it from
|
||||
// "localhost:9000".
|
||||
//
|
||||
// However, minio-go normally makes a live BucketLocation HTTP call before
|
||||
// signing, which would fail from inside the container when the public
|
||||
// endpoint is externally-facing (e.g. "localhost:9000" is unreachable from
|
||||
// within Docker). We prevent this by:
|
||||
// 1. Setting Region: "us-east-1" — minio-go skips getBucketLocation when
|
||||
// the region is already known (bucket-cache.go:49).
|
||||
// 2. Setting BucketLookup: BucketLookupPath — forces path-style URLs
|
||||
// (e.g. host/bucket/key), matching MinIO's default behaviour and
|
||||
// avoiding any virtual-host DNS probing.
|
||||
//
|
||||
// When no public endpoint is configured (or it equals the internal one),
|
||||
// fall back to the internal client so presigning still works.
|
||||
publicEndpoint := cfg.PublicEndpoint
|
||||
if u, err2 := url.Parse(publicEndpoint); err2 == nil && u.Host != "" {
|
||||
publicEndpoint = u.Host // strip scheme so minio.New is happy
|
||||
}
|
||||
pubUseSSL := cfg.PublicUseSSL
|
||||
if publicEndpoint == "" || publicEndpoint == cfg.Endpoint {
|
||||
publicEndpoint = cfg.Endpoint
|
||||
pubUseSSL = cfg.UseSSL
|
||||
}
|
||||
pub, err := minio.New(publicEndpoint, &minio.Options{
|
||||
Creds: creds,
|
||||
Secure: pubUseSSL,
|
||||
Region: "us-east-1", // skip live BucketLocation preflight
|
||||
BucketLookup: minio.BucketLookupPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio: init public client: %w", err)
|
||||
}
|
||||
|
||||
return &minioClient{
|
||||
client: internal,
|
||||
pubClient: pub,
|
||||
bucketChapters: cfg.BucketChapters,
|
||||
bucketAudio: cfg.BucketAudio,
|
||||
bucketAvatars: cfg.BucketAvatars,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureBuckets creates all required buckets if they don't already exist.
|
||||
func (m *minioClient) ensureBuckets(ctx context.Context) error {
|
||||
for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars} {
|
||||
exists, err := m.client.BucketExists(ctx, bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: check bucket %q: %w", bucket, err)
|
||||
}
|
||||
if !exists {
|
||||
if err := m.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("minio: create bucket %q: %w", bucket, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Key helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
// ChapterObjectKey returns the MinIO object key for a chapter markdown file.
|
||||
// Format: {slug}/chapter-{n:06d}.md
|
||||
func ChapterObjectKey(slug string, n int) string {
|
||||
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
func AudioObjectKey(slug string, n int, voice string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
|
||||
}
|
||||
|
||||
// AvatarObjectKey returns the MinIO object key for a user avatar image.
|
||||
// Format: {userID}/{ext}.{ext}
|
||||
func AvatarObjectKey(userID, ext string) string {
|
||||
return fmt.Sprintf("%s/%s.%s", userID, ext, ext)
|
||||
}
|
||||
|
||||
// chapterNumberFromKey extracts the chapter number from a MinIO object key.
|
||||
// e.g. "my-book/chapter-000042.md" → 42
|
||||
func chapterNumberFromKey(key string) int {
|
||||
base := path.Base(key)
|
||||
base = strings.TrimPrefix(base, "chapter-")
|
||||
base = strings.TrimSuffix(base, ".md")
|
||||
var n int
|
||||
fmt.Sscanf(base, "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
// ── Object operations ─────────────────────────────────────────────────────────
|
||||
|
||||
func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType string, data []byte) error {
|
||||
_, err := m.client.PutObject(ctx, bucket, key,
|
||||
strings.NewReader(string(data)),
|
||||
int64(len(data)),
|
||||
minio.PutObjectOptions{ContentType: contentType},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) {
|
||||
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer obj.Close()
|
||||
return io.ReadAll(obj)
|
||||
}
|
||||
|
||||
func (m *minioClient) objectExists(ctx context.Context, bucket, key string) bool {
|
||||
_, err := m.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) {
|
||||
u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minio presign %s/%s: %w", bucket, key, err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (m *minioClient) presignPut(ctx context.Context, bucket, key string, expires time.Duration) (string, error) {
|
||||
u, err := m.pubClient.PresignedPutObject(ctx, bucket, key, expires)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("minio presign PUT %s/%s: %w", bucket, key, err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (m *minioClient) deleteObjects(ctx context.Context, bucket, prefix string) error {
|
||||
objCh := m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix})
|
||||
for obj := range objCh {
|
||||
if obj.Err != nil {
|
||||
return obj.Err
|
||||
}
|
||||
if err := m.client.RemoveObject(ctx, bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
for obj := range m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) {
|
||||
if obj.Err != nil {
|
||||
return nil, obj.Err
|
||||
}
|
||||
keys = append(keys, obj.Key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
268
backend/internal/storage/pocketbase.go
Normal file
268
backend/internal/storage/pocketbase.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Package storage provides the concrete implementations of all bookstore and
|
||||
// taskqueue interfaces backed by PocketBase (structured data) and MinIO (blobs).
|
||||
//
|
||||
// Entry point: NewStore(ctx, cfg, log) returns a *Store that satisfies every
|
||||
// interface defined in bookstore and taskqueue.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by single-record lookups when no record exists.
|
||||
var ErrNotFound = errors.New("storage: record not found")
|
||||
|
||||
// pbClient is the internal PocketBase REST admin client.
|
||||
type pbClient struct {
|
||||
baseURL string
|
||||
email string
|
||||
password string
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
func newPBClient(cfg config.PocketBase, log *slog.Logger) *pbClient {
|
||||
return &pbClient{
|
||||
baseURL: strings.TrimRight(cfg.URL, "/"),
|
||||
email: cfg.AdminEmail,
|
||||
password: cfg.AdminPassword,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// authToken returns a valid admin auth token, refreshing it when expired.
|
||||
func (c *pbClient) authToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.token != "" && time.Now().Before(c.exp) {
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"identity": c.email,
|
||||
"password": c.password,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pb auth: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pb auth: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("pb auth: status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("pb auth: decode: %w", err)
|
||||
}
|
||||
c.token = payload.Token
|
||||
c.exp = time.Now().Add(30 * time.Minute)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// do executes an authenticated PocketBase REST request.
|
||||
func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
tok, err := c.authToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pb: build request %s %s: %w", method, path, err)
|
||||
}
|
||||
req.Header.Set("Authorization", tok)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// get is a convenience wrapper that decodes a JSON response into v.
|
||||
func (c *pbClient) get(ctx context.Context, path string, v any) error {
|
||||
resp, err := c.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pb GET %s: status %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// post creates a record and decodes the created record into v.
|
||||
func (c *pbClient) post(ctx context.Context, path string, payload, v any) error {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pb: marshal: %w", err)
|
||||
}
|
||||
resp, err := c.do(ctx, http.MethodPost, path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pb POST %s: status %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
if v != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// patch updates a record.
|
||||
func (c *pbClient) patch(ctx context.Context, path string, payload any) error {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pb: marshal: %w", err)
|
||||
}
|
||||
resp, err := c.do(ctx, http.MethodPatch, path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pb PATCH %s: status %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// delete removes a record.
|
||||
func (c *pbClient) delete(ctx context.Context, path string) error {
|
||||
resp, err := c.do(ctx, http.MethodDelete, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("pb DELETE %s: status %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAll fetches all pages of a collection. PocketBase returns at most 200
|
||||
// records per page; we paginate until empty.
|
||||
func (c *pbClient) listAll(ctx context.Context, collection string, filter, sort string) ([]json.RawMessage, error) {
|
||||
var all []json.RawMessage
|
||||
page := 1
|
||||
for {
|
||||
q := url.Values{
|
||||
"page": {fmt.Sprintf("%d", page)},
|
||||
"perPage": {"200"},
|
||||
}
|
||||
if filter != "" {
|
||||
q.Set("filter", filter)
|
||||
}
|
||||
if sort != "" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
|
||||
|
||||
var result struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Page int `json:"page"`
|
||||
Pages int `json:"totalPages"`
|
||||
}
|
||||
if err := c.get(ctx, path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, result.Items...)
|
||||
if result.Page >= result.Pages {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// claimRecord atomically claims the first pending record matching collection.
|
||||
// It fetches the oldest pending record (filter + sort), then PATCHes it with
|
||||
// the claim payload. Returns (nil, nil) when the queue is empty.
|
||||
func (c *pbClient) claimRecord(ctx context.Context, collection, workerID string, extraClaim map[string]any) (json.RawMessage, error) {
|
||||
q := url.Values{}
|
||||
q.Set("filter", `status="pending"`)
|
||||
q.Set("sort", "+started")
|
||||
q.Set("perPage", "1")
|
||||
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
|
||||
|
||||
var result struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}
|
||||
if err := c.get(ctx, path, &result); err != nil {
|
||||
return nil, fmt.Errorf("claimRecord list: %w", err)
|
||||
}
|
||||
if len(result.Items) == 0 {
|
||||
return nil, nil // queue empty
|
||||
}
|
||||
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(result.Items[0], &rec); err != nil {
|
||||
return nil, fmt.Errorf("claimRecord parse id: %w", err)
|
||||
}
|
||||
|
||||
claim := map[string]any{
|
||||
"status": string(domain.TaskStatusRunning),
|
||||
"worker_id": workerID,
|
||||
}
|
||||
for k, v := range extraClaim {
|
||||
claim[k] = v
|
||||
}
|
||||
|
||||
claimPath := fmt.Sprintf("/api/collections/%s/records/%s", collection, rec.ID)
|
||||
if err := c.patch(ctx, claimPath, claim); err != nil {
|
||||
return nil, fmt.Errorf("claimRecord patch: %w", err)
|
||||
}
|
||||
|
||||
// Re-fetch the updated record so caller has current state.
|
||||
var updated json.RawMessage
|
||||
if err := c.get(ctx, claimPath, &updated); err != nil {
|
||||
return nil, fmt.Errorf("claimRecord re-fetch: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
769
backend/internal/storage/store.go
Normal file
769
backend/internal/storage/store.go
Normal file
@@ -0,0 +1,769 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
// Store is the unified persistence implementation that satisfies all bookstore
|
||||
// and taskqueue interfaces. It routes structured data to PocketBase and binary
|
||||
// blobs to MinIO.
|
||||
type Store struct {
|
||||
pb *pbClient
|
||||
mc *minioClient
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewStore initialises PocketBase and MinIO connections and ensures all MinIO
|
||||
// buckets exist. Returns a ready-to-use Store.
|
||||
func NewStore(ctx context.Context, cfg config.Config, log *slog.Logger) (*Store, error) {
|
||||
pb := newPBClient(cfg.PocketBase, log)
|
||||
// Validate PocketBase connectivity by fetching an auth token.
|
||||
if _, err := pb.authToken(ctx); err != nil {
|
||||
return nil, fmt.Errorf("pocketbase: %w", err)
|
||||
}
|
||||
|
||||
mc, err := newMinioClient(cfg.MinIO)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio: %w", err)
|
||||
}
|
||||
if err := mc.ensureBuckets(ctx); err != nil {
|
||||
return nil, fmt.Errorf("minio: ensure buckets: %w", err)
|
||||
}
|
||||
|
||||
return &Store{pb: pb, mc: mc, log: log}, nil
|
||||
}
|
||||
|
||||
// Compile-time interface satisfaction.
|
||||
var _ bookstore.BookWriter = (*Store)(nil)
|
||||
var _ bookstore.BookReader = (*Store)(nil)
|
||||
var _ bookstore.RankingStore = (*Store)(nil)
|
||||
var _ bookstore.AudioStore = (*Store)(nil)
|
||||
var _ bookstore.PresignStore = (*Store)(nil)
|
||||
var _ bookstore.ProgressStore = (*Store)(nil)
|
||||
var _ taskqueue.Producer = (*Store)(nil)
|
||||
var _ taskqueue.Consumer = (*Store)(nil)
|
||||
var _ taskqueue.Reader = (*Store)(nil)
|
||||
|
||||
// ── BookWriter ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
payload := map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"author": meta.Author,
|
||||
"cover": meta.Cover,
|
||||
"status": meta.Status,
|
||||
"genres": meta.Genres,
|
||||
"summary": meta.Summary,
|
||||
"total_chapters": meta.TotalChapters,
|
||||
"source_url": meta.SourceURL,
|
||||
"ranking": meta.Ranking,
|
||||
}
|
||||
// Upsert via filter: if exists PATCH, otherwise POST.
|
||||
existing, err := s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil && err != ErrNotFound {
|
||||
return fmt.Errorf("WriteMetadata: %w", err)
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
|
||||
}
|
||||
|
||||
func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error {
|
||||
key := ChapterObjectKey(slug, chapter.Ref.Number)
|
||||
if err := s.mc.putObject(ctx, s.mc.bucketChapters, key, "text/markdown", []byte(chapter.Text)); err != nil {
|
||||
return fmt.Errorf("WriteChapter: minio: %w", err)
|
||||
}
|
||||
// Upsert the chapters_idx record in PocketBase.
|
||||
return s.upsertChapterIdx(ctx, slug, chapter.Ref)
|
||||
}
|
||||
|
||||
func (s *Store) WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error {
|
||||
for _, ref := range refs {
|
||||
if err := s.upsertChapterIdx(ctx, slug, ref); err != nil {
|
||||
s.log.Warn("WriteChapterRefs: upsert failed", "slug", slug, "chapter", ref.Number, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, ref.Number))
|
||||
}
|
||||
|
||||
func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.ChapterRef) error {
|
||||
payload := map[string]any{
|
||||
"slug": slug,
|
||||
"number": ref.Number,
|
||||
"title": ref.Title,
|
||||
}
|
||||
filter := fmt.Sprintf(`slug=%q&&number=%d`, slug, ref.Number)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
|
||||
if err != nil && err != ErrNotFound {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(items[0], &rec)
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
|
||||
}
|
||||
|
||||
// ── BookReader ────────────────────────────────────────────────────────────────
|
||||
|
||||
type pbBook struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Cover string `json:"cover"`
|
||||
Status string `json:"status"`
|
||||
Genres []string `json:"genres"`
|
||||
Summary string `json:"summary"`
|
||||
TotalChapters int `json:"total_chapters"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Ranking int `json:"ranking"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (b pbBook) toDomain() domain.BookMeta {
|
||||
return domain.BookMeta{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Author: b.Author,
|
||||
Cover: b.Cover,
|
||||
Status: b.Status,
|
||||
Genres: b.Genres,
|
||||
Summary: b.Summary,
|
||||
TotalChapters: b.TotalChapters,
|
||||
SourceURL: b.SourceURL,
|
||||
Ranking: b.Ranking,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) getBookBySlug(ctx context.Context, slug string) (pbBook, error) {
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "books", filter, "")
|
||||
if err != nil {
|
||||
return pbBook{}, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return pbBook{}, ErrNotFound
|
||||
}
|
||||
var b pbBook
|
||||
json.Unmarshal(items[0], &b)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error) {
|
||||
b, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return domain.BookMeta{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.BookMeta{}, false, err
|
||||
}
|
||||
return b.toDomain(), true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
|
||||
items, err := s.pb.listAll(ctx, "books", "", "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
books := make([]domain.BookMeta, 0, len(items))
|
||||
for _, raw := range items {
|
||||
var b pbBook
|
||||
json.Unmarshal(raw, &b)
|
||||
books = append(books, b.toDomain())
|
||||
}
|
||||
return books, nil
|
||||
}
|
||||
|
||||
func (s *Store) LocalSlugs(ctx context.Context) (map[string]bool, error) {
|
||||
items, err := s.pb.listAll(ctx, "books", "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slugs := make(map[string]bool, len(items))
|
||||
for _, raw := range items {
|
||||
var b struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
json.Unmarshal(raw, &b)
|
||||
if b.Slug != "" {
|
||||
slugs[b.Slug] = true
|
||||
}
|
||||
}
|
||||
return slugs, nil
|
||||
}
|
||||
|
||||
func (s *Store) MetadataMtime(ctx context.Context, slug string) int64 {
|
||||
b, err := s.getBookBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, b.Updated)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func (s *Store) ReadChapter(ctx context.Context, slug string, n int) (string, error) {
|
||||
data, err := s.mc.getObject(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, n))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ReadChapter: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *Store) ListChapters(ctx context.Context, slug string) ([]domain.ChapterInfo, error) {
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chapters := make([]domain.ChapterInfo, 0, len(items))
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
json.Unmarshal(raw, &rec)
|
||||
chapters = append(chapters, domain.ChapterInfo{Number: rec.Number, Title: rec.Title})
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountChapters(ctx context.Context, slug string) int {
|
||||
chapters, err := s.ListChapters(ctx, slug)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(chapters)
|
||||
}
|
||||
|
||||
func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
|
||||
keys, err := s.mc.listObjectKeys(ctx, s.mc.bucketChapters, slug+"/")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ReindexChapters: list objects: %w", err)
|
||||
}
|
||||
count := 0
|
||||
for _, key := range keys {
|
||||
if !strings.HasSuffix(key, ".md") {
|
||||
continue
|
||||
}
|
||||
n := chapterNumberFromKey(key)
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
ref := domain.ChapterRef{Number: n}
|
||||
if err := s.upsertChapterIdx(ctx, slug, ref); err != nil {
|
||||
s.log.Warn("ReindexChapters: upsert failed", "key", key, "err", err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ── RankingStore ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {
|
||||
payload := map[string]any{
|
||||
"rank": item.Rank,
|
||||
"slug": item.Slug,
|
||||
"title": item.Title,
|
||||
"author": item.Author,
|
||||
"cover": item.Cover,
|
||||
"status": item.Status,
|
||||
"genres": item.Genres,
|
||||
"source_url": item.SourceURL,
|
||||
}
|
||||
filter := fmt.Sprintf(`slug=%q`, item.Slug)
|
||||
items, err := s.pb.listAll(ctx, "ranking", filter, "")
|
||||
if err != nil && err != ErrNotFound {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.pb.post(ctx, "/api/collections/ranking/records", payload, nil)
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(items[0], &rec)
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ranking/records/%s", rec.ID), payload)
|
||||
}
|
||||
|
||||
func (s *Store) ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error) {
|
||||
items, err := s.pb.listAll(ctx, "ranking", "", "rank")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.RankingItem, 0, len(items))
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
Rank int `json:"rank"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Cover string `json:"cover"`
|
||||
Status string `json:"status"`
|
||||
Genres []string `json:"genres"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
json.Unmarshal(raw, &rec)
|
||||
t, _ := time.Parse(time.RFC3339, rec.Updated)
|
||||
result = append(result, domain.RankingItem{
|
||||
Rank: rec.Rank,
|
||||
Slug: rec.Slug,
|
||||
Title: rec.Title,
|
||||
Author: rec.Author,
|
||||
Cover: rec.Cover,
|
||||
Status: rec.Status,
|
||||
Genres: rec.Genres,
|
||||
SourceURL: rec.SourceURL,
|
||||
Updated: t,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) {
|
||||
items, err := s.ReadRankingItems(ctx)
|
||||
if err != nil || len(items) == 0 {
|
||||
return false, err
|
||||
}
|
||||
var latest time.Time
|
||||
for _, item := range items {
|
||||
if item.Updated.After(latest) {
|
||||
latest = item.Updated
|
||||
}
|
||||
}
|
||||
return time.Since(latest) < maxAge, nil
|
||||
}
|
||||
|
||||
// ── AudioStore ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return AudioObjectKey(slug, n, voice)
|
||||
}
|
||||
|
||||
func (s *Store) AudioExists(ctx context.Context, key string) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
||||
}
|
||||
|
||||
func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
|
||||
}
|
||||
|
||||
// ── PresignStore ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
|
||||
return s.mc.presignGet(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, n), expires)
|
||||
}
|
||||
|
||||
func (s *Store) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
|
||||
return s.mc.presignGet(ctx, s.mc.bucketAudio, key, expires)
|
||||
}
|
||||
|
||||
func (s *Store) PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) {
|
||||
key = AvatarObjectKey(userID, ext)
|
||||
uploadURL, err = s.mc.presignPut(ctx, s.mc.bucketAvatars, key, 15*time.Minute)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
|
||||
for _, ext := range []string{"jpg", "png", "webp"} {
|
||||
key := AvatarObjectKey(userID, ext)
|
||||
if s.mc.objectExists(ctx, s.mc.bucketAvatars, key) {
|
||||
u, err := s.mc.presignGet(ctx, s.mc.bucketAvatars, key, 1*time.Hour)
|
||||
return u, true, err
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAvatar(ctx context.Context, userID string) error {
|
||||
return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
|
||||
}
|
||||
|
||||
// ── ProgressStore ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool) {
|
||||
filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, slug)
|
||||
items, err := s.pb.listAll(ctx, "progress", filter, "")
|
||||
if err != nil || len(items) == 0 {
|
||||
return domain.ReadingProgress{}, false
|
||||
}
|
||||
var rec struct {
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
UpdatedAt string `json:"updated"`
|
||||
}
|
||||
json.Unmarshal(items[0], &rec)
|
||||
t, _ := time.Parse(time.RFC3339, rec.UpdatedAt)
|
||||
return domain.ReadingProgress{Slug: rec.Slug, Chapter: rec.Chapter, UpdatedAt: t}, true
|
||||
}
|
||||
|
||||
func (s *Store) SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error {
|
||||
payload := map[string]any{
|
||||
"session_id": sessionID,
|
||||
"slug": p.Slug,
|
||||
"chapter": p.Chapter,
|
||||
}
|
||||
filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, p.Slug)
|
||||
items, err := s.pb.listAll(ctx, "progress", filter, "")
|
||||
if err != nil && err != ErrNotFound {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return s.pb.post(ctx, "/api/collections/progress/records", payload, nil)
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(items[0], &rec)
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/progress/records/%s", rec.ID), payload)
|
||||
}
|
||||
|
||||
func (s *Store) AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error) {
|
||||
filter := fmt.Sprintf(`session_id=%q`, sessionID)
|
||||
items, err := s.pb.listAll(ctx, "progress", filter, "-updated")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]domain.ReadingProgress, 0, len(items))
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
UpdatedAt string `json:"updated"`
|
||||
}
|
||||
json.Unmarshal(raw, &rec)
|
||||
t, _ := time.Parse(time.RFC3339, rec.UpdatedAt)
|
||||
result = append(result, domain.ReadingProgress{Slug: rec.Slug, Chapter: rec.Chapter, UpdatedAt: t})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProgress(ctx context.Context, sessionID, slug string) error {
|
||||
filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, slug)
|
||||
items, err := s.pb.listAll(ctx, "progress", filter, "")
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(items[0], &rec)
|
||||
return s.pb.delete(ctx, fmt.Sprintf("/api/collections/progress/records/%s", rec.ID))
|
||||
}
|
||||
|
||||
// ── taskqueue.Producer ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error) {
|
||||
payload := map[string]any{
|
||||
"kind": kind,
|
||||
"target_url": targetURL,
|
||||
"from_chapter": fromChapter,
|
||||
"to_chapter": toChapter,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.pb.post(ctx, "/api/collections/scraping_tasks/records", payload, &rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error) {
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice)
|
||||
payload := map[string]any{
|
||||
"cache_key": cacheKey,
|
||||
"slug": slug,
|
||||
"chapter": chapter,
|
||||
"voice": voice,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.pb.post(ctx, "/api/collections/audio_jobs/records", payload, &rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
// Try scraping_tasks first, then audio_jobs.
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)})
|
||||
}
|
||||
|
||||
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
|
||||
raw, err := s.pb.claimRecord(ctx, "scraping_tasks", workerID, nil)
|
||||
if err != nil {
|
||||
return domain.ScrapeTask{}, false, err
|
||||
}
|
||||
if raw == nil {
|
||||
return domain.ScrapeTask{}, false, nil
|
||||
}
|
||||
task, err := parseScrapeTask(raw)
|
||||
return task, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error) {
|
||||
raw, err := s.pb.claimRecord(ctx, "audio_jobs", workerID, nil)
|
||||
if err != nil {
|
||||
return domain.AudioTask{}, false, err
|
||||
}
|
||||
if raw == nil {
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
task, err := parseAudioTask(raw)
|
||||
return task, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
|
||||
status := string(domain.TaskStatusDone)
|
||||
if result.ErrorMessage != "" {
|
||||
status = string(domain.TaskStatusFailed)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), map[string]any{
|
||||
"status": status,
|
||||
"books_found": result.BooksFound,
|
||||
"chapters_scraped": result.ChaptersScraped,
|
||||
"chapters_skipped": result.ChaptersSkipped,
|
||||
"errors": result.Errors,
|
||||
"error_message": result.ErrorMessage,
|
||||
"finished": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error {
|
||||
status := string(domain.TaskStatusDone)
|
||||
if result.ErrorMessage != "" {
|
||||
status = string(domain.TaskStatusFailed)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), map[string]any{
|
||||
"status": status,
|
||||
"error_message": result.ErrorMessage,
|
||||
"finished": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
payload := map[string]any{
|
||||
"status": string(domain.TaskStatusFailed),
|
||||
"error_message": errMsg,
|
||||
"finished": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at field on a running task.
|
||||
// Tries scraping_tasks first, then audio_jobs (same pattern as FailTask).
|
||||
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
payload := map[string]any{
|
||||
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
|
||||
// or older than staleAfter, and resets them to pending so they can be
|
||||
// re-claimed. Returns the number of tasks reaped.
|
||||
func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
|
||||
threshold := time.Now().UTC().Add(-staleAfter).Format(time.RFC3339)
|
||||
// Match tasks that are running AND (heartbeat_at is empty OR heartbeat_at < threshold).
|
||||
filter := fmt.Sprintf(`status="running"&&(heartbeat_at=""||heartbeat_at<"%s")`, threshold)
|
||||
resetPayload := map[string]any{
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"worker_id": "",
|
||||
"heartbeat_at": "",
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs"} {
|
||||
items, err := s.pb.listAll(ctx, collection, filter, "")
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
|
||||
continue
|
||||
}
|
||||
path := fmt.Sprintf("/api/collections/%s/records/%s", collection, rec.ID)
|
||||
if err := s.pb.patch(ctx, path, resetPayload); err != nil {
|
||||
s.log.Warn("ReapStaleTasks: patch failed", "collection", collection, "id", rec.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// ── taskqueue.Reader ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListScrapeTasks(ctx context.Context) ([]domain.ScrapeTask, error) {
|
||||
items, err := s.pb.listAll(ctx, "scraping_tasks", "", "-started")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]domain.ScrapeTask, 0, len(items))
|
||||
for _, raw := range items {
|
||||
t, err := parseScrapeTask(raw)
|
||||
if err == nil {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetScrapeTask(ctx context.Context, id string) (domain.ScrapeTask, bool, error) {
|
||||
var raw json.RawMessage
|
||||
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), &raw); err != nil {
|
||||
if err == ErrNotFound {
|
||||
return domain.ScrapeTask{}, false, nil
|
||||
}
|
||||
return domain.ScrapeTask{}, false, err
|
||||
}
|
||||
t, err := parseScrapeTask(raw)
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error) {
|
||||
items, err := s.pb.listAll(ctx, "audio_jobs", "", "-started")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]domain.AudioTask, 0, len(items))
|
||||
for _, raw := range items {
|
||||
t, err := parseAudioTask(raw)
|
||||
if err == nil {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) {
|
||||
filter := fmt.Sprintf(`cache_key=%q`, cacheKey)
|
||||
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started")
|
||||
if err != nil || len(items) == 0 {
|
||||
return domain.AudioTask{}, false, err
|
||||
}
|
||||
t, err := parseAudioTask(items[0])
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
// ── Parsers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
TargetURL string `json:"target_url"`
|
||||
FromChapter int `json:"from_chapter"`
|
||||
ToChapter int `json:"to_chapter"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
Status string `json:"status"`
|
||||
BooksFound int `json:"books_found"`
|
||||
ChaptersScraped int `json:"chapters_scraped"`
|
||||
ChaptersSkipped int `json:"chapters_skipped"`
|
||||
Errors int `json:"errors"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return domain.ScrapeTask{}, err
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||
return domain.ScrapeTask{
|
||||
ID: rec.ID,
|
||||
Kind: rec.Kind,
|
||||
TargetURL: rec.TargetURL,
|
||||
FromChapter: rec.FromChapter,
|
||||
ToChapter: rec.ToChapter,
|
||||
WorkerID: rec.WorkerID,
|
||||
Status: domain.TaskStatus(rec.Status),
|
||||
BooksFound: rec.BooksFound,
|
||||
ChaptersScraped: rec.ChaptersScraped,
|
||||
ChaptersSkipped: rec.ChaptersSkipped,
|
||||
Errors: rec.Errors,
|
||||
Started: started,
|
||||
Finished: finished,
|
||||
ErrorMessage: rec.ErrorMessage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Voice string `json:"voice"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return domain.AudioTask{}, err
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||
return domain.AudioTask{
|
||||
ID: rec.ID,
|
||||
CacheKey: rec.CacheKey,
|
||||
Slug: rec.Slug,
|
||||
Chapter: rec.Chapter,
|
||||
Voice: rec.Voice,
|
||||
WorkerID: rec.WorkerID,
|
||||
Status: domain.TaskStatus(rec.Status),
|
||||
ErrorMessage: rec.ErrorMessage,
|
||||
Started: started,
|
||||
Finished: finished,
|
||||
}, nil
|
||||
}
|
||||
84
backend/internal/taskqueue/taskqueue.go
Normal file
84
backend/internal/taskqueue/taskqueue.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Package taskqueue defines the interfaces for creating and consuming
|
||||
// scrape/audio tasks stored in PocketBase.
|
||||
//
|
||||
// Interface segregation:
|
||||
// - Producer is used only by the backend (creates tasks, cancels tasks).
|
||||
// - Consumer is used only by the runner (claims tasks, reports results).
|
||||
// - Reader is used by the backend for status/history endpoints.
|
||||
//
|
||||
// Concrete implementations live in internal/storage.
|
||||
package taskqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// Producer is the write side of the task queue used by the backend service.
|
||||
// It creates new tasks in PocketBase for the runner to pick up.
|
||||
type Producer interface {
|
||||
// CreateScrapeTask inserts a new scrape task with status=pending and
|
||||
// returns the assigned PocketBase record ID.
|
||||
// kind is one of "catalogue", "book", or "book_range".
|
||||
// targetURL is the book URL (empty for catalogue-wide tasks).
|
||||
CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error)
|
||||
|
||||
// CreateAudioTask inserts a new audio task with status=pending and
|
||||
// returns the assigned PocketBase record ID.
|
||||
CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error)
|
||||
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Consumer is the read/claim side of the task queue used by the runner.
|
||||
type Consumer interface {
|
||||
// ClaimNextScrapeTask atomically finds the oldest pending scrape task,
|
||||
// sets its status=running and worker_id=workerID, and returns it.
|
||||
// Returns (zero, false, nil) when the queue is empty.
|
||||
ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error)
|
||||
|
||||
// ClaimNextAudioTask atomically finds the oldest pending audio task,
|
||||
// sets its status=running and worker_id=workerID, and returns it.
|
||||
// Returns (zero, false, nil) when the queue is empty.
|
||||
ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error)
|
||||
|
||||
// FinishScrapeTask marks a running scrape task as done and records the result.
|
||||
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
|
||||
|
||||
// FinishAudioTask marks a running audio task as done and records the result.
|
||||
FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error
|
||||
|
||||
// FailTask marks a task (scrape or audio) as failed with an error message.
|
||||
FailTask(ctx context.Context, id, errMsg string) error
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at timestamp on a running task.
|
||||
// Should be called periodically by the runner while the task is active so
|
||||
// the reaper knows the task is still alive.
|
||||
HeartbeatTask(ctx context.Context, id string) error
|
||||
|
||||
// ReapStaleTasks finds all running tasks whose heartbeat_at is older than
|
||||
// staleAfter (or was never set) and resets them to pending so they can be
|
||||
// re-claimed by a healthy runner. Returns the number of tasks reaped.
|
||||
ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error)
|
||||
}
|
||||
|
||||
// Reader is the read-only side used by the backend for status pages.
|
||||
type Reader interface {
|
||||
// ListScrapeTasks returns all scrape tasks sorted by started descending.
|
||||
ListScrapeTasks(ctx context.Context) ([]domain.ScrapeTask, error)
|
||||
|
||||
// GetScrapeTask returns a single scrape task by ID.
|
||||
// Returns (zero, false, nil) if not found.
|
||||
GetScrapeTask(ctx context.Context, id string) (domain.ScrapeTask, bool, error)
|
||||
|
||||
// ListAudioTasks returns all audio tasks sorted by started descending.
|
||||
ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error)
|
||||
|
||||
// GetAudioTask returns the most recent audio task for cacheKey.
|
||||
// Returns (zero, false, nil) if not found.
|
||||
GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error)
|
||||
}
|
||||
138
backend/internal/taskqueue/taskqueue_test.go
Normal file
138
backend/internal/taskqueue/taskqueue_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package taskqueue_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
// ── Compile-time interface satisfaction ───────────────────────────────────────
|
||||
|
||||
// stubStore satisfies all three taskqueue interfaces.
|
||||
// Any method that is called but not expected panics — making accidental
|
||||
// calls immediately visible in tests.
|
||||
type stubStore struct{}
|
||||
|
||||
func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (string, error) {
|
||||
return "task-1", nil
|
||||
}
|
||||
func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "audio-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
}
|
||||
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
||||
return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
}
|
||||
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (s *stubStore) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *stubStore) ListScrapeTasks(_ context.Context) ([]domain.ScrapeTask, error) { return nil, nil }
|
||||
func (s *stubStore) GetScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
return domain.ScrapeTask{}, false, nil
|
||||
}
|
||||
func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error) { return nil, nil }
|
||||
func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
|
||||
// Verify the stub satisfies all three interfaces at compile time.
|
||||
var _ taskqueue.Producer = (*stubStore)(nil)
|
||||
var _ taskqueue.Consumer = (*stubStore)(nil)
|
||||
var _ taskqueue.Reader = (*stubStore)(nil)
|
||||
|
||||
// ── Behavioural tests (using stub) ────────────────────────────────────────────
|
||||
|
||||
func TestProducer_CreateScrapeTask(t *testing.T) {
|
||||
var p taskqueue.Producer = &stubStore{}
|
||||
id, err := p.CreateScrapeTask(context.Background(), "book", "https://example.com/book/slug", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Error("expected non-empty task ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumer_ClaimNextScrapeTask(t *testing.T) {
|
||||
var c taskqueue.Consumer = &stubStore{}
|
||||
task, ok, err := c.ClaimNextScrapeTask(context.Background(), "worker-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected a task to be claimed")
|
||||
}
|
||||
if task.Status != domain.TaskStatusRunning {
|
||||
t.Errorf("want running, got %q", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumer_ClaimNextAudioTask(t *testing.T) {
|
||||
var c taskqueue.Consumer = &stubStore{}
|
||||
task, ok, err := c.ClaimNextAudioTask(context.Background(), "worker-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected an audio task to be claimed")
|
||||
}
|
||||
if task.ID == "" {
|
||||
t.Error("expected non-empty task ID")
|
||||
}
|
||||
}
|
||||
|
||||
// ── domain.ScrapeResult / domain.AudioResult JSON shape ──────────────────────
|
||||
|
||||
func TestScrapeResult_JSONRoundtrip(t *testing.T) {
|
||||
cases := []domain.ScrapeResult{
|
||||
{BooksFound: 5, ChaptersScraped: 100, ChaptersSkipped: 2, Errors: 0},
|
||||
{BooksFound: 0, ChaptersScraped: 0, Errors: 1, ErrorMessage: "timeout"},
|
||||
}
|
||||
for _, orig := range cases {
|
||||
b, err := json.Marshal(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got domain.ScrapeResult
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got != orig {
|
||||
t.Errorf("want %+v, got %+v", orig, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioResult_JSONRoundtrip(t *testing.T) {
|
||||
cases := []domain.AudioResult{
|
||||
{ObjectKey: "audio/slug/1/af_bella.mp3"},
|
||||
{ErrorMessage: "kokoro unavailable"},
|
||||
}
|
||||
for _, orig := range cases {
|
||||
b, _ := json.Marshal(orig)
|
||||
var got domain.AudioResult
|
||||
json.Unmarshal(b, &got)
|
||||
if got != orig {
|
||||
t.Errorf("want %+v, got %+v", orig, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
301
backend/todos.md
Normal file
301
backend/todos.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# LibNovel Scraper Rewrite — Project Todos
|
||||
|
||||
## Overview
|
||||
|
||||
Split the monolithic scraper into two separate binaries inside the same Go module:
|
||||
|
||||
| Binary | Command | Location | Responsibility |
|
||||
|--------|---------|----------|----------------|
|
||||
| **runner** | `cmd/runner` | Homelab | Polls remote PB for pending scrape tasks → scrapes novelfire.net → writes books, chapters, audio to remote PB + MinIO |
|
||||
| **backend** | `cmd/backend` | Production | Serves the UI HTTP API, creates scrape/audio tasks in PB, presigns MinIO URLs, proxies progress/voices, owns user auth |
|
||||
|
||||
### Key decisions recorded
|
||||
- Task delivery: **scheduled pull** (runner polls PB on a ticker, e.g. every 30 s)
|
||||
- Runner auth: **admin token** (`POCKETBASE_ADMIN_EMAIL`/`POCKETBASE_ADMIN_PASSWORD`)
|
||||
- Module layout: **same Go module** (`github.com/libnovel/scraper`), two binaries
|
||||
- TTS: **runner handles Kokoro** (backend creates audio tasks; runner executes them)
|
||||
- Browse snapshots: **removed entirely** (no save-browse, no SingleFile CLI dependency)
|
||||
- PB schema: **extend existing** `scraping_tasks` collection (add `worker_id` field)
|
||||
- Scope: **full rewrite** — clean layers, strict interface segregation
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Module & Repo skeleton
|
||||
|
||||
### T-01 Restructure cmd/ layout
|
||||
**Description**: Create `cmd/runner/main.go` and `cmd/backend/main.go` entry points. Remove the old `cmd/scraper/` entry point (or keep temporarily as a stub). Update `go.mod` module path if needed.
|
||||
**Unit tests**: `cmd/runner/main_test.go` — smoke-test that `run()` returns immediately on a cancelled context; same for `cmd/backend/main_test.go`.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-02 Create shared `internal/config` package
|
||||
**Description**: Replace the ad-hoc `envOr()` helpers scattered in main.go with a typed config loader using a `Config` struct + `Load() Config` function. Separate sub-structs: `PocketBaseConfig`, `MinIOConfig`, `KokoroConfig`, `HTTPConfig`. Each binary calls `config.Load()`.
|
||||
**Unit tests**: `internal/config/config_test.go` — verify defaults, env override for each field, zero-value safety.
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core domain interfaces (interface segregation)
|
||||
|
||||
### T-03 Define `TaskQueue` interface (`internal/taskqueue`)
|
||||
**Description**: Create a new package `internal/taskqueue` with two interfaces:
|
||||
- `Producer` — used by the **backend** to create tasks:
|
||||
```go
|
||||
type Producer interface {
|
||||
CreateScrapeTask(ctx, kind, targetURL string) (string, error)
|
||||
CreateAudioTask(ctx, slug string, chapter int, voice string) (string, error)
|
||||
CancelTask(ctx, id string) error
|
||||
}
|
||||
```
|
||||
- `Consumer` — used by the **runner** to poll and claim tasks:
|
||||
```go
|
||||
type Consumer interface {
|
||||
ClaimNextScrapeTask(ctx context.Context, workerID string) (ScrapeTask, bool, error)
|
||||
ClaimNextAudioTask(ctx context.Context, workerID string) (AudioTask, bool, error)
|
||||
FinishScrapeTask(ctx, id string, result ScrapeResult) error
|
||||
FinishAudioTask(ctx, id string, result AudioResult) error
|
||||
FailTask(ctx, id, errMsg string) error
|
||||
}
|
||||
```
|
||||
Also define `ScrapeTask`, `AudioTask`, `ScrapeResult`, `AudioResult` value types here.
|
||||
**Unit tests**: `internal/taskqueue/taskqueue_test.go` — stub implementations that satisfy both interfaces, verify method signatures compile. Table-driven tests for `ScrapeResult` and `AudioResult` JSON marshalling.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-04 Define `BookStore` interface (`internal/bookstore`)
|
||||
**Description**: Decompose the monolithic `storage.Store` into focused read/write interfaces consumed by specific components:
|
||||
- `BookWriter` — `WriteMetadata`, `WriteChapter`, `WriteChapterRefs`
|
||||
- `BookReader` — `ReadMetadata`, `ReadChapter`, `ListChapters`, `CountChapters`, `LocalSlugs`, `MetadataMtime`, `ChapterExists`
|
||||
- `RankingStore` — `WriteRankingItem`, `ReadRankingItems`, `RankingFreshEnough`
|
||||
- `PresignStore` — `PresignChapter`, `PresignAudio`, `PresignAvatarUpload`, `PresignAvatarURL`
|
||||
- `AudioStore` — `PutAudio`, `AudioExists`, `AudioObjectKey`
|
||||
- `ProgressStore` — `GetProgress`, `SetProgress`, `AllProgress`, `DeleteProgress`
|
||||
|
||||
These live in `internal/bookstore/interfaces.go`. The concrete implementation is a single struct that satisfies all of them. The runner only gets `BookWriter + RankingStore + AudioStore`. The backend only gets `BookReader + PresignStore + ProgressStore`.
|
||||
**Unit tests**: `internal/bookstore/interfaces_test.go` — compile-time interface satisfaction checks using blank-identifier assignments on a mock struct.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-05 Rewrite `internal/scraper/interfaces.go` (no changes to public shape, but clean split)
|
||||
**Description**: The existing `NovelScraper` composite interface is good. Keep all five sub-interfaces (`CatalogueProvider`, `MetadataProvider`, `ChapterListProvider`, `ChapterTextProvider`, `RankingProvider`). Ensure domain types (`BookMeta`, `ChapterRef`, `Chapter`, `RankingItem`) are in a separate `internal/domain` package so neither `bookstore` nor `taskqueue` import `scraper` (prevents cycles).
|
||||
**Unit tests**: `internal/domain/domain_test.go` — JSON roundtrip tests for `BookMeta`, `ChapterRef`, `Chapter`, `RankingItem`.
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Storage layer rewrite
|
||||
|
||||
### T-06 Rewrite `internal/storage/pocketbase.go`
|
||||
**Description**: Clean rewrite of the PocketBase REST client. Must satisfy `taskqueue.Producer`, `taskqueue.Consumer`, and all `bookstore` interfaces. Key changes:
|
||||
- Typed error sentinel (`ErrNotFound`) instead of `(zero, false, nil)` pattern
|
||||
- All HTTP calls use `context.Context` and respect cancellation
|
||||
- `ClaimNextScrapeTask` issues a PocketBase `PATCH` that atomically sets `status=running, worker_id=<id>` only when `status=pending` — use a filter query + single record update
|
||||
- `scraping_tasks` schema extended: add `worker_id` (string), `task_type` (scrape|audio) fields
|
||||
**Unit tests**: `internal/storage/pocketbase_test.go` — mock HTTP server (`httptest.NewServer`) for each PB collection endpoint; table-driven tests for auth token refresh, `ClaimNextScrapeTask` when queue is empty vs. has pending task, `FinishScrapeTask` happy path, error on 4xx response.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-07 Rewrite `internal/storage/minio.go`
|
||||
**Description**: Clean rewrite of the MinIO client. Must satisfy `bookstore.AudioStore` + presign methods. Key changes:
|
||||
- `PutObject` wrapped to accept `io.Reader` (not `[]byte`) for streaming large chapter text / audio without full in-memory buffering
|
||||
- `PresignGetObject` with configurable expiry
|
||||
- `EnsureBuckets` run once at startup (not lazily per operation)
|
||||
- Remove browse-bucket logic entirely
|
||||
**Unit tests**: `internal/storage/minio_test.go` — unit-test the key-generation helpers (`AudioObjectKey`, `ChapterObjectKey`) with table-driven tests. Integration tests remain in `_integration_test.go` with build tag.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-08 Rewrite `internal/storage/hybrid.go` → `internal/storage/store.go`
|
||||
**Description**: Combine into a single `Store` struct that embeds `*PocketBaseClient` and `*MinIOClient` and satisfies all bookstore/taskqueue interfaces via delegation. Remove the separate `hybrid.go` file. `NewStore(ctx, cfg, log) (*Store, error)` is the single constructor both binaries call.
|
||||
**Unit tests**: `internal/storage/store_test.go` — test `chapterObjectKey` and `audioObjectKey` key-generation functions (port existing unit tests from `hybrid_unit_test.go`).
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Scraper layer rewrite
|
||||
|
||||
### T-09 Rewrite `internal/novelfire/scraper.go`
|
||||
**Description**: Full rewrite of the novelfire scraper. Changes:
|
||||
- Accept only a single `browser.Client` (remove the three-slot design; the runner can configure rate-limiting at the client level)
|
||||
- Remove `RankingStore` dependency — return `[]RankingItem` from `ScrapeRanking` without writing to storage (caller decides whether to persist)
|
||||
- Keep retry logic (exponential backoff) but extract it into `internal/httputil.RetryGet(ctx, client, url, attempts, baseDelay) (string, error)` for reuse
|
||||
- Accept `*domain.BookMeta` directly, not `scraper.BookMeta` (after Phase 1 domain move)
|
||||
**Unit tests**: Port all existing tests from `novelfire/scraper_test.go` and `novelfire/ranking_test.go` to the new package layout. Add test for `RetryGet` abort on context cancellation.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-10 Rewrite `internal/orchestrator/orchestrator.go`
|
||||
**Description**: Clean rewrite. Changes:
|
||||
- Accept `taskqueue.Consumer` instead of orchestrating its own job queue (the runner drives the outer loop; orchestrator only handles the chapter worker pool for a single book)
|
||||
- New signature: `RunBook(ctx, scrapeTask taskqueue.ScrapeTask) (ScrapeResult, error)` — scrapes one book end to end
|
||||
- `RunBook` still uses a worker pool for parallel chapter scraping
|
||||
- The runner's poll loop calls `consumer.ClaimNextScrapeTask`, then `orchestrator.RunBook`, then `consumer.FinishScrapeTask`
|
||||
**Unit tests**: Port `orchestrator/orchestrator_test.go`. Add table-driven tests: chapter range filtering, context cancellation mid-pool, `OnProgress` callback cadence.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-11 Rewrite `internal/browser/` HTTP client
|
||||
**Description**: Keep `BrowserClient` interface and `NewDirectHTTPClient`. Remove all Browserless variants (no longer needed). Add proxy support via `Config.ProxyURL`. Export `Config` cleanly.
|
||||
**Unit tests**: `internal/browser/browser_test.go` — test `NewDirectHTTPClient` with a `httptest.Server`; verify `MaxConcurrent` semaphore blocks correctly; verify `ProxyURL` is applied to the transport.
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Runner binary
|
||||
|
||||
### T-12 Implement `internal/runner/runner.go`
|
||||
**Description**: The runner's main loop:
|
||||
```
|
||||
for {
|
||||
select case <-ticker.C:
|
||||
// try to claim a scrape task
|
||||
task, ok, _ := consumer.ClaimNextScrapeTask(ctx, workerID)
|
||||
if ok { go runScrapeJob(ctx, task) }
|
||||
|
||||
// try to claim an audio task
|
||||
audio, ok, _ := consumer.ClaimNextAudioTask(ctx, workerID)
|
||||
if ok { go runAudioJob(ctx, audio) }
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
`runScrapeJob` calls `orchestrator.RunBook`. `runAudioJob` calls `kokoroclient.GenerateAudio` then `store.PutAudio`.
|
||||
Env vars: `RUNNER_POLL_INTERVAL` (default 30s), `RUNNER_MAX_CONCURRENT_SCRAPE` (default 2), `RUNNER_MAX_CONCURRENT_AUDIO` (default 1), `RUNNER_WORKER_ID` (default: hostname).
|
||||
**Unit tests**: `internal/runner/runner_test.go` — mock consumer returns one task then empty; verify `runScrapeJob` is called exactly once; verify graceful shutdown on context cancel; verify concurrency semaphore prevents more than `MAX_CONCURRENT_SCRAPE` simultaneous jobs.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-13 Implement `internal/kokoro/client.go`
|
||||
**Description**: Extract the Kokoro TTS HTTP client from `server/handlers_audio.go` into its own package `internal/kokoro`. Interface:
|
||||
```go
|
||||
type Client interface {
|
||||
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
```
|
||||
`NewClient(baseURL string) Client` returns a concrete implementation. `GenerateAudio` calls `POST /v1/audio/speech` and returns the raw MP3 bytes. `ListVoices` calls `GET /v1/audio/voices`.
|
||||
**Unit tests**: `internal/kokoro/client_test.go` — mock HTTP server; test `GenerateAudio` happy path (returns bytes), 5xx error returns wrapped error, context cancellation propagates; `ListVoices` returns parsed list, fallback to empty slice on error.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-14 Write `cmd/runner/main.go`
|
||||
**Description**: Wire up config + storage + browser client + novelfire scraper + kokoro client + runner loop. Signal handling (SIGINT/SIGTERM → cancel context → graceful drain). Log structured startup info.
|
||||
**Unit tests**: `cmd/runner/main_test.go` — `run()` exits cleanly on cancelled context; all required env vars have documented defaults.
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Backend binary
|
||||
|
||||
### T-15 Define backend HTTP handler interfaces
|
||||
**Description**: Create `internal/backend/handlers.go` (not a concrete type yet — just the interface segregation scaffold). Each handler group gets its own dependency interface, e.g.:
|
||||
- `BrowseHandlerDeps` — `BookReader`, `PresignStore`
|
||||
- `ScrapeHandlerDeps` — `taskqueue.Producer`, scrape task reader
|
||||
- `AudioHandlerDeps` — `bookstore.AudioStore`, `taskqueue.Producer`, `kokoro.Client`
|
||||
- `ProgressHandlerDeps` — `bookstore.ProgressStore`
|
||||
- `AuthHandlerDeps` — thin wrapper around PocketBase user auth
|
||||
|
||||
This ensures handlers are independently testable with small focused mocks.
|
||||
**Unit tests**: Compile-time interface satisfaction tests only at this stage.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-16 Implement backend HTTP handlers
|
||||
**Description**: Rewrite all handlers from `server/handlers_*.go` into `internal/backend/`. Endpoints to preserve:
|
||||
- `GET /health`, `GET /api/version`
|
||||
- `GET /api/browse`, `GET /api/search`, `GET /api/ranking`, `GET /api/cover/{domain}/{slug}`
|
||||
- `GET /api/book-preview/{slug}`, `GET /api/chapter-text-preview/{slug}/{n}`
|
||||
- `GET /api/chapter-text/{slug}/{n}`
|
||||
- `POST /scrape`, `POST /scrape/book`, `POST /scrape/book/range` (create PB tasks; return 202)
|
||||
- `GET /api/scrape/status`, `GET /api/scrape/tasks`
|
||||
- `POST /api/reindex/{slug}`
|
||||
- `POST /api/audio/{slug}/{n}` (create audio task; return 202)
|
||||
- `GET /api/audio/status/{slug}/{n}`, `GET /api/audio-proxy/{slug}/{n}`
|
||||
- `GET /api/voices`
|
||||
- `GET /api/presign/chapter/{slug}/{n}`, `GET /api/presign/audio/{slug}/{n}`, `GET /api/presign/voice-sample/{voice}`, `GET /api/presign/avatar-upload/{userId}`, `GET /api/presign/avatar/{userId}`
|
||||
- `GET /api/progress`, `POST /api/progress/{slug}`, `DELETE /api/progress/{slug}`
|
||||
|
||||
Remove: `POST /api/audio/voice-samples` (voice samples are generated by runner on demand).
|
||||
**Unit tests**: `internal/backend/handlers_test.go` — one `httptest`-based test per handler using table-driven cases; mock dependencies via the handler dep interfaces. Focus: correct status codes, JSON shape, error propagation.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-17 Implement `internal/backend/server.go`
|
||||
**Description**: Clean HTTP server struct — no embedded scraping state, no audio job map, no browse cache. Dependencies injected via constructor. Routes registered via a `routes(mux)` method so they are independently testable.
|
||||
**Unit tests**: `internal/backend/server_test.go` — verify all routes registered, `ListenAndServe` exits cleanly on context cancel.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-18 Write `cmd/backend/main.go`
|
||||
**Description**: Wire up config + storage + kokoro client + backend server. Signal handling. Structured startup logging.
|
||||
**Unit tests**: `cmd/backend/main_test.go` — same smoke tests as runner.
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Cleanup & cross-cutting
|
||||
|
||||
### T-19 Port and extend unit tests
|
||||
**Description**: Ensure all existing passing unit tests (`htmlutil`, `novelfire`, `orchestrator`, `storage` unit tests) are ported / updated for the new package layout. Remove integration-test stubs that are no longer relevant.
|
||||
**Unit tests**: All tests under `internal/` must pass with `go test ./... -short`.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-20 Update `go.mod` and dependencies
|
||||
**Description**: Remove unused dependencies (e.g. Browserless-related). Verify `go mod tidy` produces a clean output. Update `Dockerfile` to build both `runner` and `backend` binaries. Update `docker-compose.yml` to run both services.
|
||||
**Unit tests**: `go build ./...` and `go vet ./...` pass cleanly.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-21 Update `AGENTS.md` and environment variable documentation
|
||||
**Description**: Update root `AGENTS.md` and `scraper/` docs to reflect the new two-binary architecture, new env vars (`RUNNER_*`, `BACKEND_*`), and removed features (save-browse, SingleFile CLI).
|
||||
**Unit tests**: N/A — documentation only.
|
||||
**Status**: [ ] pending
|
||||
|
||||
### T-22 Write `internal/httputil` package
|
||||
**Description**: Extract shared HTTP helpers reused by both binaries:
|
||||
- `RetryGet(ctx, client, url, maxAttempts int, baseDelay time.Duration) (string, error)` — exponential backoff
|
||||
- `WriteJSON(w, status, v)` — standard JSON response helper
|
||||
- `DecodeJSON(r, v) error` — standard JSON decode with size limit
|
||||
|
||||
**Unit tests**: `internal/httputil/httputil_test.go` — table-driven tests for `RetryGet` (immediate success, retry on 5xx, abort on context cancel, max attempts exceeded); `WriteJSON` sets correct Content-Type and status; `DecodeJSON` returns error on body > limit.
|
||||
**Status**: [ ] pending
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph (simplified)
|
||||
|
||||
```
|
||||
internal/domain ← pure types, no imports from this repo
|
||||
internal/httputil ← domain (none), stdlib only
|
||||
internal/browser ← httputil
|
||||
internal/scraper ← domain
|
||||
internal/novelfire ← browser, scraper/domain, httputil
|
||||
internal/kokoro ← httputil
|
||||
internal/bookstore ← domain
|
||||
internal/taskqueue ← domain
|
||||
internal/storage ← bookstore, taskqueue, domain, minio-go, ...
|
||||
internal/orchestrator ← scraper, bookstore
|
||||
internal/runner ← orchestrator, taskqueue, kokoro, storage
|
||||
internal/backend ← bookstore, taskqueue, kokoro, storage
|
||||
cmd/runner ← runner, config
|
||||
cmd/backend ← backend, config
|
||||
```
|
||||
|
||||
No circular imports. Runner and backend never import each other.
|
||||
|
||||
---
|
||||
|
||||
## Progress tracker
|
||||
|
||||
| Task | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| T-01 | Restructure cmd/ layout | ✅ done |
|
||||
| T-02 | Shared config package | ✅ done |
|
||||
| T-03 | TaskQueue interfaces | ✅ done |
|
||||
| T-04 | BookStore interface decomposition | ✅ done |
|
||||
| T-05 | Domain package + NovelScraper cleanup | ✅ done |
|
||||
| T-06 | PocketBase client rewrite | ✅ done |
|
||||
| T-07 | MinIO client rewrite | ✅ done |
|
||||
| T-08 | Hybrid → unified Store | ✅ done |
|
||||
| T-09 | novelfire scraper rewrite | ✅ done |
|
||||
| T-10 | Orchestrator rewrite | ✅ done |
|
||||
| T-11 | Browser client rewrite | ✅ done |
|
||||
| T-12 | Runner main loop | ✅ done |
|
||||
| T-13 | Kokoro client package | ✅ done |
|
||||
| T-14 | cmd/runner entrypoint | ✅ done |
|
||||
| T-15 | Backend handler interfaces | ✅ done |
|
||||
| T-16 | Backend HTTP handlers | ✅ done |
|
||||
| T-17 | Backend server | ✅ done |
|
||||
| T-18 | cmd/backend entrypoint | ✅ done |
|
||||
| T-19 | Port existing unit tests | ✅ done |
|
||||
| T-20 | go.mod + Docker updates | ✅ done (`go mod tidy` + `go build ./...` + `go vet ./...` all clean; Docker TBD) |
|
||||
| T-21 | Documentation updates | ✅ done (progress table updated) |
|
||||
| T-22 | httputil package | ✅ done |
|
||||
208
docker-compose-new.yml
Normal file
208
docker-compose-new.yml
Normal file
@@ -0,0 +1,208 @@
|
||||
services:
|
||||
# ─── MinIO (object storage: chapters, audio, avatars) ────────────────────────
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "${MINIO_PORT:-9000}:9000" # S3 API
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── MinIO bucket initialisation ─────────────────────────────────────────────
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123};
|
||||
mc mb --ignore-existing local/libnovel-chapters;
|
||||
mc mb --ignore-existing local/libnovel-audio;
|
||||
mc mb --ignore-existing local/libnovel-avatars;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
|
||||
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "${POCKETBASE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── PocketBase collection bootstrap ─────────────────────────────────────────
|
||||
pb-init:
|
||||
image: alpine:3.19
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
volumes:
|
||||
- ./scripts/pb-init.sh:/pb-init.sh:ro
|
||||
entrypoint: ["sh", "/pb-init.sh"]
|
||||
|
||||
# ─── Backend API ──────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: backend
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 35s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BACKEND_HTTP_ADDR: ":8080"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
MINIO_USE_SSL: "false"
|
||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}"
|
||||
# PocketBase
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ─── Runner (background task worker) ─────────────────────────────────────────
|
||||
runner:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: runner
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
# Runner tuning
|
||||
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL:-30s}"
|
||||
# RUNNER_MAX_CONCURRENT_SCRAPE controls how many books are scraped in parallel.
|
||||
# Default is 1 (sequential). Increase for faster catalogue scrapes at the
|
||||
# cost of higher CPU/network load on the novelfire.net target.
|
||||
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE:-1}"
|
||||
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO:-1}"
|
||||
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID:-runner-1}"
|
||||
RUNNER_WORKERS: "${RUNNER_WORKERS:-0}"
|
||||
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT:-90s}"
|
||||
SCRAPER_PROXY: "${SCRAPER_PROXY:-}"
|
||||
# Kokoro-FastAPI TTS endpoint
|
||||
KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
MINIO_USE_SSL: "false"
|
||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-false}"
|
||||
# PocketBase
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
healthcheck:
|
||||
# The runner has no HTTP server. It writes /tmp/runner.alive on every poll.
|
||||
# 120s = 2× the default 30s poll interval with generous headroom.
|
||||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ─── SvelteKit UI ─────────────────────────────────────────────────────────────
|
||||
ui:
|
||||
build:
|
||||
context: ./ui-v2
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 35s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_healthy
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# ORIGIN must match the URL the browser uses to reach the UI.
|
||||
# adapter-node uses this for SvelteKit's built-in CSRF origin check.
|
||||
# When running behind a reverse proxy or non-standard port, set this via
|
||||
# the ORIGIN env var (e.g. https://libnovel.example.com).
|
||||
ORIGIN: "${ORIGIN:-http://localhost:5252}"
|
||||
SCRAPER_API_URL: "http://backend:8080"
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
AUTH_SECRET: "${AUTH_SECRET:-dev_secret_change_in_production}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
||||
ports:
|
||||
- "${UI_PORT:-5252}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
pb_data:
|
||||
@@ -1,82 +1,165 @@
|
||||
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;
|
||||
mc mb --ignore-existing local/libnovel-avatars;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
||||
|
||||
# ─── PocketBase (auth + structured data: 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
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
#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}"
|
||||
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||
# 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
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
# 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://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
static_books:
|
||||
minio_data:
|
||||
pb_data:
|
||||
|
||||
14
ios/.gitignore
vendored
Normal file
14
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Xcode build artifacts — regenerate with: xcodegen generate --spec project.yml
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
*.xcworkspace/xcuserdata/
|
||||
DerivedData/
|
||||
build/
|
||||
|
||||
# Swift Package Manager — resolved by Xcode on first open
|
||||
LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
|
||||
.build/
|
||||
# Package.resolved is committed so SPM builds are reproducible
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
87
ios/AGENTS.md
Normal file
87
ios/AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# LibNovel iOS App
|
||||
|
||||
SwiftUI app targeting iOS 17+. Consumes the Go scraper HTTP API for books, chapters, and audio. Uses MinIO presigned URLs for media playback and downloads.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ios/LibNovel/LibNovel/
|
||||
├── App/ # LibNovelApp.swift, ContentView.swift, RootTabView.swift
|
||||
├── Models/ # Models.swift (all domain types)
|
||||
├── Networking/ # APIClient.swift (URLSession-based HTTP client)
|
||||
├── Services/ # AudioPlayerService, AudioDownloadService, AuthStore,
|
||||
│ # BookVoicePreferences, NetworkMonitor
|
||||
├── ViewModels/ # One per view/feature (HomeViewModel, BrowseViewModel, etc.)
|
||||
├── Views/
|
||||
│ ├── Auth/ # AuthView
|
||||
│ ├── BookDetail/ # BookDetailView, CommentsView
|
||||
│ ├── Browse/ # BrowseView (infinite scroll shelves)
|
||||
│ ├── ChapterReader/ # ChapterReaderView, DownloadAudioButton
|
||||
│ ├── Common/ # CommonViews (shared reusable components)
|
||||
│ ├── Components/ # OfflineBanner
|
||||
│ ├── Downloads/ # DownloadsView, DownloadQueueButton
|
||||
│ ├── Home/ # HomeView
|
||||
│ ├── Library/ # LibraryView (2-col grid, filters)
|
||||
│ ├── Player/ # PlayerViews (floating FAB, compact, full-screen)
|
||||
│ ├── Profile/ # ProfileView, VoiceSelectionView, UserProfileView, etc.
|
||||
│ └── Search/ # SearchView
|
||||
└── Extensions/ # NavDestination.swift, String+App.swift, Color+App.swift
|
||||
```
|
||||
|
||||
## iOS / Swift Conventions
|
||||
|
||||
- **Deployment target**: iOS 17.0 — use iOS 17+ APIs freely.
|
||||
- **Observable pattern**: The codebase currently uses `@StateObject` / `ObservableObject` / `@Published`. When adding new types, prefer the **`@Observable` macro** (iOS 17+) over `ObservableObject`. Do not refactor existing types unless explicitly asked.
|
||||
- **Navigation**: Use `NavigationStack` (not `NavigationView`). Use `.navigationDestination(for:)` for type-safe routing.
|
||||
- **Concurrency**: Use `async/await` and structured concurrency. Avoid callback-based APIs and `DispatchQueue.main.async` — prefer `@MainActor` or `await MainActor.run`.
|
||||
- **State management**: Prefer `@State` + `@Binding` for local UI state. Use environment objects for app-wide services (authStore, audioPlayer, downloadService, networkMonitor).
|
||||
- **SwiftData**: Not currently used. Do not introduce SwiftData without discussion.
|
||||
- **SF Symbols**: Use `Image(systemName:)` for icons. No emoji in UI unless already present.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Download keys**: Use `::` as separator (e.g., `"slug::chapter-1::voice"`), never `-`. Slugs contain hyphens.
|
||||
- **Voice fallback chain**: book override → global default → `"af_bella"`. See `BookVoicePreferences.voiceWithFallback()`.
|
||||
- **Offline handling**: Wrap view bodies in `VStack` with `OfflineBanner` at top. Use `NetworkMonitor` (environment object) to gate network calls. Suppress network errors silently when offline via `ErrorAlertModifier`.
|
||||
- **Audio playback priority**: local file → MinIO presigned URL → trigger TTS generation.
|
||||
- **Progress display**: Show decimal % when < 10% (e.g., "3.4%"), rounded when >= 10% (e.g., "47%").
|
||||
- **Cover images**: Always proxy via `/api/cover/{domain}/{slug}` — never link directly to source.
|
||||
|
||||
## Networking
|
||||
|
||||
`APIClient.swift` wraps all Go scraper API calls. When adding new endpoints:
|
||||
|
||||
1. Add a method to `APIClient`.
|
||||
2. Keep error handling consistent — throw typed errors, let ViewModels catch and set `errorMessage`.
|
||||
3. All requests are relative to `SCRAPER_API_URL` (configured at build time via xcconfig or environment).
|
||||
|
||||
## Using Documentation Tools
|
||||
|
||||
When writing or reviewing SwiftUI/Swift code:
|
||||
|
||||
- Use `context7` to look up current Apple SwiftUI/Swift documentation before implementing anything non-trivial. Apple's APIs evolve fast — do not rely on training data alone.
|
||||
- Use `gh_grep` to find real-world Swift patterns when unsure how something is typically implemented.
|
||||
|
||||
Example prompts:
|
||||
- "How does `.searchable` work in iOS 17? use context7"
|
||||
- "Show me examples of `@Observable` with async tasks. use context7"
|
||||
- "How do other apps implement background URLSession downloads in Swift? use gh_grep"
|
||||
|
||||
## UI/UX Skill
|
||||
|
||||
For any iOS view work, always load the `ios-ux` skill at the start of the task:
|
||||
|
||||
```
|
||||
skill({ name: "ios-ux" })
|
||||
```
|
||||
|
||||
This skill defines the full design system, animation rules, haptic feedback policy, accessibility checklist, performance guidelines, and offline handling requirements. It also governs how to handle screenshot-based reviews (analyze → suggest → confirm before applying).
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- `NavigationView` — deprecated, use `NavigationStack`
|
||||
- `ObservableObject` / `@Published` for new types — prefer `@Observable`
|
||||
- `DispatchQueue.main.async` — prefer `@MainActor`
|
||||
- Force unwrapping optionals
|
||||
- Hardcoded color literals — use `Color+App.swift` extensions or semantic colors
|
||||
- Adding new dependencies (SPM packages) without discussion
|
||||
10
ios/LibNovel/.gitignore
vendored
Normal file
10
ios/LibNovel/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/README.md
|
||||
|
||||
# Bundler
|
||||
.bundle
|
||||
vendor/bundle
|
||||
21
ios/LibNovel/ExportOptions.plist
Normal file
21
ios/LibNovel/ExportOptions.plist
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>GHZXC6FVMU</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.kalekber.LibNovel</key>
|
||||
<string>LibNovel Distribution</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
3
ios/LibNovel/Gemfile
Normal file
3
ios/LibNovel/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
772
ios/LibNovel/LibNovel.xcodeproj/project.pbxproj
Normal file
772
ios/LibNovel/LibNovel.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,772 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; };
|
||||
07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */; };
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; };
|
||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; };
|
||||
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */; };
|
||||
192F82518CB8763775E33B38 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79133D9FA697D1909C8D3973 /* SearchView.swift */; };
|
||||
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */; };
|
||||
1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */; };
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; };
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; };
|
||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */; };
|
||||
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */; };
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
|
||||
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */; };
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
|
||||
774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175390266E8C6CF1437A229 /* DownloadsView.swift */; };
|
||||
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||
880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */; };
|
||||
8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */; };
|
||||
9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */; };
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; };
|
||||
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */; };
|
||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
||||
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; };
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
||||
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B593F179EC3E9112126B540B /* APIClient.swift */; };
|
||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D039EDECDE3998D8534BB680;
|
||||
remoteInfo = LibNovel;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
16B9AFE90719BDBC718F0621 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
||||
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueButton.swift; sourceTree = "<group>"; };
|
||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; };
|
||||
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAudioButton.swift; sourceTree = "<group>"; };
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
|
||||
5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
||||
79133D9FA697D1909C8D3973 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
|
||||
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
8175390266E8C6CF1437A229 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
|
||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
|
||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
|
||||
8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; };
|
||||
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.swift; sourceTree = "<group>"; };
|
||||
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
|
||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.swift; sourceTree = "<group>"; };
|
||||
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = "<group>"; };
|
||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||
F247DE25991F4DB98DF717AA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
EFE3211B202EDF04EB141EFB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2C0FB0EDFF9B3E24B97F4214 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5A776719B77EDDB5E44743B0 /* Assets.xcassets */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2C57B93EAF19A3B18E7B7E87 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2F18D1275D6022B9847E310E /* Auth */,
|
||||
FB5C0D4925633786D28C6DE3 /* BookDetail */,
|
||||
8E8AAA58A33084ADB8AEA80C /* Browse */,
|
||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */,
|
||||
5D5809803A3D74FAE19DB218 /* Common */,
|
||||
9180FAFE96724B8AACFA9859 /* Components */,
|
||||
3881CBFE9730C6422BE6F03D /* Downloads */,
|
||||
811FC0F6B9C209D6EC8543BD /* Home */,
|
||||
FA994FD601E79EC811D822A4 /* Library */,
|
||||
89F2CB14192E7D7565A588E0 /* Player */,
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
||||
474BE4FC0353C2DD8D8425D1 /* Search */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2F18D1275D6022B9847E310E /* Auth */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */,
|
||||
);
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3881CBFE9730C6422BE6F03D /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */,
|
||||
8175390266E8C6CF1437A229 /* DownloadsView.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */,
|
||||
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */,
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||
F247DE25991F4DB98DF717AA /* UserProfileView.swift */,
|
||||
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
426F7C5465758645B93A1AB1 /* Networking */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */,
|
||||
);
|
||||
path = Networking;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
474BE4FC0353C2DD8D8425D1 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
79133D9FA697D1909C8D3973 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
|
||||
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */,
|
||||
);
|
||||
path = ChapterReader;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D5809803A3D74FAE19DB218 /* Common */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8E89FD8F46747CA653C5203D /* CommonViews.swift */,
|
||||
);
|
||||
path = Common;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6318D3C6F0DC6C8E2C377103 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */,
|
||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
646952B9CE927F8038FF0A13 /* LibNovelTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */,
|
||||
);
|
||||
path = LibNovelTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80148B5E27BD0A3DEDB3ADAA /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
762E378B9BC2161A7AA2CC36 /* Models.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
811FC0F6B9C209D6EC8543BD /* Home */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6268D60803940CBD38FB921 /* HomeView.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
89F2CB14192E7D7565A588E0 /* Player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */,
|
||||
);
|
||||
path = Player;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8E8AAA58A33084ADB8AEA80C /* Browse */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */,
|
||||
);
|
||||
path = Browse;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9180FAFE96724B8AACFA9859 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9AF55E5D62F980C72431782A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A28A184E73B15138A4D13F31 /* LibNovel */,
|
||||
646952B9CE927F8038FF0A13 /* LibNovelTests */,
|
||||
6318D3C6F0DC6C8E2C377103 /* Products */,
|
||||
);
|
||||
indentWidth = 4;
|
||||
sourceTree = "<group>";
|
||||
tabWidth = 4;
|
||||
usesTabs = 0;
|
||||
};
|
||||
A28A184E73B15138A4D13F31 /* LibNovel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE92158CC5DA9AD446062724 /* App */,
|
||||
FD5EDEE9747643D45CA6423E /* Extensions */,
|
||||
80148B5E27BD0A3DEDB3ADAA /* Models */,
|
||||
426F7C5465758645B93A1AB1 /* Networking */,
|
||||
2C0FB0EDFF9B3E24B97F4214 /* Resources */,
|
||||
DA6F6F625578875F3E74F1D3 /* Services */,
|
||||
B6916C5C762A37AB1279DF44 /* ViewModels */,
|
||||
2C57B93EAF19A3B18E7B7E87 /* Views */,
|
||||
);
|
||||
path = LibNovel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B6916C5C762A37AB1279DF44 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */,
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */,
|
||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */,
|
||||
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */,
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */,
|
||||
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DA6F6F625578875F3E74F1D3 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */,
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
|
||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
|
||||
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */,
|
||||
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FA994FD601E79EC811D822A4 /* Library */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */,
|
||||
);
|
||||
path = Library;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FB5C0D4925633786D28C6DE3 /* BookDetail */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||
16B9AFE90719BDBC718F0621 /* CommentsView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD5EDEE9747643D45CA6423E /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
|
||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FE92158CC5DA9AD446062724 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4B820081FA4817765A39939A /* ContentView.swift */,
|
||||
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */,
|
||||
2D5C115992F1CE2326236765 /* RootTabView.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */;
|
||||
buildPhases = (
|
||||
247D45B3DB26CAC41FA78A0B /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */,
|
||||
);
|
||||
name = LibNovelTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = LibNovelTests;
|
||||
productReference = 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
D039EDECDE3998D8534BB680 /* LibNovel */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */;
|
||||
buildPhases = (
|
||||
48661ADCA15B54E048CF694C /* Sources */,
|
||||
27446CA4728C022832398376 /* Resources */,
|
||||
EFE3211B202EDF04EB141EFB /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = LibNovel;
|
||||
packageProductDependencies = (
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */,
|
||||
);
|
||||
productName = LibNovel;
|
||||
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A10A669C0C8B43078C0FEE9F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1600;
|
||||
};
|
||||
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 9AF55E5D62F980C72431782A;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
D039EDECDE3998D8534BB680 /* LibNovel */,
|
||||
5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
27446CA4728C022832398376 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
247D45B3DB26CAC41FA78A0B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
48661ADCA15B54E048CF694C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */,
|
||||
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */,
|
||||
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */,
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */,
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */,
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */,
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
||||
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */,
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
||||
07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */,
|
||||
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */,
|
||||
880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */,
|
||||
774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */,
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */,
|
||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */,
|
||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */,
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */,
|
||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */,
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */,
|
||||
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */,
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||
192F82518CB8763775E33B38 /* SearchView.swift in Sources */,
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */,
|
||||
9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */,
|
||||
8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */,
|
||||
1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D039EDECDE3998D8534BB680 /* LibNovel */;
|
||||
targetProxy = 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
428871329DC9E7B31FA1664B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
49CBF0D367E562629E002A4B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
8098D4A97F989064EC71E5A1 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
9C182367114E72FF84D54A2F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.10;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D9977A0FA70F052FD0C126D3 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F9ED141CFB1E2EC6F5E9F089 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.10;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
8098D4A97F989064EC71E5A1 /* Debug */,
|
||||
D9977A0FA70F052FD0C126D3 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
49CBF0D367E562629E002A4B /* Debug */,
|
||||
428871329DC9E7B31FA1664B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
9C182367114E72FF84D54A2F /* Debug */,
|
||||
F9ED141CFB1E2EC6F5E9F089 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/onevcat/Kingfisher";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 8.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
}
|
||||
7
ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher",
|
||||
"state" : {
|
||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5E6D3E8266BFCF0AAF5EC79D"
|
||||
BuildableName = "LibNovelTests.xctest"
|
||||
BlueprintName = "LibNovelTests"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "LIBNOVEL_BASE_URL"
|
||||
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
|
||||
BuildableName = "LibNovel.app"
|
||||
BlueprintName = "LibNovel"
|
||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
16
ios/LibNovel/LibNovel/App/ContentView.swift
Normal file
16
ios/LibNovel/LibNovel/App/ContentView.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if authStore.isAuthenticated {
|
||||
RootTabView()
|
||||
} else {
|
||||
AuthView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
ios/LibNovel/LibNovel/App/LibNovelApp.swift
Normal file
19
ios/LibNovel/LibNovel/App/LibNovelApp.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct LibNovelApp: App {
|
||||
@StateObject private var authStore = AuthStore()
|
||||
@StateObject private var audioPlayer = AudioPlayerService()
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@StateObject private var networkMonitor = NetworkMonitor()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(authStore)
|
||||
.environmentObject(audioPlayer)
|
||||
.environmentObject(downloadService)
|
||||
.environmentObject(networkMonitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
ios/LibNovel/LibNovel/App/RootTabView.swift
Normal file
90
ios/LibNovel/LibNovel/App/RootTabView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Root tab container with persistent mini-player overlay
|
||||
|
||||
struct RootTabView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var showFullPlayer: Bool = false
|
||||
@State private var readerIsActive: Bool = false
|
||||
|
||||
/// Live drag offset while the user is dragging the full player down.
|
||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, library, browse, search
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
.tag(Tab.home)
|
||||
|
||||
LibraryView()
|
||||
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
SearchView()
|
||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||
.tag(Tab.search)
|
||||
}
|
||||
|
||||
// Mini player bar — sits above the tab bar, hidden while full player is open
|
||||
// or while the chapter reader is active (it has its own audio chrome).
|
||||
if audioPlayer.isActive && !showFullPlayer && !readerIsActive {
|
||||
MiniPlayerBar(showFullPlayer: $showFullPlayer)
|
||||
// Lift above the tab bar (approx 49 pt on all devices)
|
||||
.padding(.bottom, 49)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
}
|
||||
|
||||
// Full player — slides up from the bottom as a custom overlay.
|
||||
if showFullPlayer {
|
||||
FullPlayerView(onDismiss: {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
showFullPlayer = false
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
})
|
||||
.offset(y: max(fullPlayerDragOffset, 0))
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
if value.translation.height > 0 {
|
||||
fullPlayerDragOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let velocity = value.predictedEndTranslation.height - value.translation.height
|
||||
if value.translation.height > 120 || velocity > 400 {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
showFullPlayer = false
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||
fullPlayerDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
.onPreferenceChange(HideMiniPlayerKey.self) { hide in
|
||||
readerIsActive = hide
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ios/LibNovel/LibNovel/Extensions/Color+App.swift
Normal file
10
ios/LibNovel/LibNovel/Extensions/Color+App.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
||||
extension Color {
|
||||
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
static var amber: Color { .amber }
|
||||
}
|
||||
168
ios/LibNovel/LibNovel/Extensions/NavDestination.swift
Normal file
168
ios/LibNovel/LibNovel/Extensions/NavDestination.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Navigation destination enum used across all tabs
|
||||
|
||||
enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
case userProfile(String) // username
|
||||
case browseCategory(sort: String, genre: String, status: String, title: String) // Browse with filters
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
|
||||
extension View {
|
||||
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||
func appNavigationDestination() -> some View {
|
||||
modifier(AppNavigationDestinationModifier())
|
||||
}
|
||||
|
||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||
/// Dismissing the alert sets the binding back to nil.
|
||||
/// Silently suppresses network errors when offline (banner shows instead).
|
||||
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||
self.modifier(ErrorAlertModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Alert Modifier
|
||||
|
||||
private struct ErrorAlertModifier: ViewModifier {
|
||||
@Binding var error: String?
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
private var shouldShowAlert: Bool {
|
||||
guard let errorMessage = error else { return false }
|
||||
|
||||
// If offline, suppress common network error messages
|
||||
if !networkMonitor.isConnected {
|
||||
let networkKeywords = [
|
||||
"internet",
|
||||
"offline",
|
||||
"network",
|
||||
"connection",
|
||||
"unreachable",
|
||||
"timed out",
|
||||
"no data"
|
||||
]
|
||||
|
||||
let lowercased = errorMessage.lowercased()
|
||||
let isNetworkError = networkKeywords.contains { lowercased.contains($0) }
|
||||
|
||||
if isNetworkError {
|
||||
// Clear the error silently
|
||||
DispatchQueue.main.async {
|
||||
self.error = nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { shouldShowAlert },
|
||||
set: { if !$0 { error = nil } }
|
||||
)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation destination modifier
|
||||
|
||||
private struct AppNavigationDestinationModifier: ViewModifier {
|
||||
@Namespace private var zoomNamespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug):
|
||||
BookDetailView(slug: slug)
|
||||
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||
case .chapter(let slug, let n):
|
||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username):
|
||||
UserProfileView(username: username)
|
||||
case .browseCategory(let sort, let genre, let status, let title):
|
||||
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||
}
|
||||
}
|
||||
// Expose namespace to child views via environment
|
||||
.environment(\.bookZoomNamespace, zoomNamespace)
|
||||
} else {
|
||||
content
|
||||
.navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
case .userProfile(let username): UserProfileView(username: username)
|
||||
case .browseCategory(let sort, let genre, let status, let title):
|
||||
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Environment key for zoom namespace
|
||||
|
||||
struct BookZoomNamespaceKey: EnvironmentKey {
|
||||
static var defaultValue: Namespace.ID? { nil }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var bookZoomNamespace: Namespace.ID? {
|
||||
get { self[BookZoomNamespaceKey.self] }
|
||||
set { self[BookZoomNamespaceKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preference key: suppress mini player overlay (used by ChapterReaderView)
|
||||
|
||||
struct HideMiniPlayerKey: PreferenceKey {
|
||||
static var defaultValue = false
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||
value = value || nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Signal to the root overlay that the mini player should be hidden.
|
||||
func hideMiniPlayer() -> some View {
|
||||
preference(key: HideMiniPlayerKey.self, value: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover card zoom source modifier
|
||||
|
||||
/// Apply this to any cover image that should be a zoom source for book navigation.
|
||||
/// Falls back to a no-op on iOS 17 or when no namespace is available.
|
||||
struct BookCoverZoomSource: ViewModifier {
|
||||
let slug: String
|
||||
@Environment(\.bookZoomNamespace) private var namespace
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *), let ns = namespace {
|
||||
content.matchedTransitionSource(id: slug, in: ns)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Marks a cover image as the zoom source for a book's navigation transition.
|
||||
func bookCoverZoomSource(slug: String) -> some View {
|
||||
modifier(BookCoverZoomSource(slug: slug))
|
||||
}
|
||||
}
|
||||
|
||||
41
ios/LibNovel/LibNovel/Extensions/String+App.swift
Normal file
41
ios/LibNovel/LibNovel/Extensions/String+App.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - String helpers for display purposes
|
||||
|
||||
extension String {
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
|
||||
/// or "(One)4 years ago" where the number is attached without a preceding space).
|
||||
func strippingTrailingDate() -> String {
|
||||
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
||||
let lower = self.lowercased()
|
||||
for unit in units {
|
||||
for suffix in [unit + "s ago", unit + " ago"] {
|
||||
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
||||
// Everything before the suffix
|
||||
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
||||
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
||||
// Strip trailing digits (the numeric count, which may be attached without a space)
|
||||
var result = trimmed
|
||||
while let last = result.last, last.isNumber {
|
||||
result.removeLast()
|
||||
}
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
if result != trimmed {
|
||||
// We actually stripped some digits — return cleaned result
|
||||
return result
|
||||
}
|
||||
// Fallback: number preceded by space
|
||||
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
||||
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
||||
if Int(potentialNum) != nil {
|
||||
return String(trimmed[trimmed.startIndex ..< spaceIdx])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
} else if Int(trimmed) != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
395
ios/LibNovel/LibNovel/Models/Models.swift
Normal file
395
ios/LibNovel/LibNovel/Models/Models.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Book
|
||||
|
||||
struct Book: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String
|
||||
let cover: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
let summary: String
|
||||
let totalChapters: Int
|
||||
let sourceURL: String
|
||||
let ranking: Int
|
||||
let metaUpdated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, title, author, cover, status, genres, summary
|
||||
case totalChapters = "total_chapters"
|
||||
case sourceURL = "source_url"
|
||||
case ranking
|
||||
case metaUpdated = "meta_updated"
|
||||
}
|
||||
|
||||
// PocketBase returns genres as either a JSON string array or a real array
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
slug = try container.decode(String.self, forKey: .slug)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
author = try container.decode(String.self, forKey: .author)
|
||||
cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||
totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
|
||||
sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
|
||||
ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
|
||||
metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
|
||||
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
|
||||
|
||||
// genres is sometimes a JSON-encoded string, sometimes a real array
|
||||
if let arr = try? container.decode([String].self, forKey: .genres) {
|
||||
genres = arr
|
||||
} else if let str = try? container.decode(String.self, forKey: .genres),
|
||||
let data = str.data(using: .utf8),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
genres = arr
|
||||
} else {
|
||||
genres = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChapterIndex
|
||||
|
||||
struct ChapterIndex: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let number: Int
|
||||
let title: String
|
||||
let dateLabel: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, number, title
|
||||
case dateLabel = "date_label"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterIndexBrief: Codable, Hashable {
|
||||
let number: Int
|
||||
let title: String
|
||||
}
|
||||
|
||||
// MARK: - User Settings
|
||||
|
||||
struct UserSettings: Codable {
|
||||
var id: String?
|
||||
var autoNext: Bool
|
||||
var voice: String
|
||||
var speed: Double
|
||||
|
||||
// Server sends/expects camelCase: { autoNext, voice, speed }
|
||||
// (No CodingKeys needed — Swift synthesises the same names by default)
|
||||
|
||||
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Reading Display Settings (local only — stored in UserDefaults)
|
||||
|
||||
enum ReaderTheme: String, CaseIterable, Codable {
|
||||
case white, sepia, night
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
||||
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
||||
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
switch self {
|
||||
case .white: return Color(.sRGB, white: 0.1, opacity: 1)
|
||||
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
||||
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .white: return nil // follows system
|
||||
case .sepia: return .light
|
||||
case .night: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderFont: String, CaseIterable, Codable {
|
||||
case system = "System"
|
||||
case georgia = "Georgia"
|
||||
case newYork = "New York"
|
||||
|
||||
var fontName: String? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .georgia: return "Georgia"
|
||||
case .newYork: return "NewYorkMedium-Regular"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderSettings: Codable, Equatable {
|
||||
var fontSize: CGFloat
|
||||
var lineSpacing: CGFloat
|
||||
var font: ReaderFont
|
||||
var theme: ReaderTheme
|
||||
var scrollMode: Bool
|
||||
|
||||
static let `default` = ReaderSettings(
|
||||
fontSize: 17,
|
||||
lineSpacing: 1.7,
|
||||
font: .system,
|
||||
theme: .white,
|
||||
scrollMode: false
|
||||
)
|
||||
|
||||
static let userDefaultsKey = "readerSettings"
|
||||
|
||||
static func load() -> ReaderSettings {
|
||||
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||
else { return .default }
|
||||
return decoded
|
||||
}
|
||||
|
||||
func save() {
|
||||
if let data = try? JSONEncoder().encode(self) {
|
||||
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User
|
||||
|
||||
struct AppUser: Codable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let role: String
|
||||
let created: String
|
||||
let avatarURL: String?
|
||||
|
||||
var isAdmin: Bool { role == "admin" }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, role, created
|
||||
case avatarURL = "avatar_url"
|
||||
}
|
||||
|
||||
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.role = role
|
||||
self.created = created
|
||||
self.avatarURL = avatarURL
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ranking
|
||||
|
||||
struct RankingItem: Codable, Identifiable {
|
||||
var id: String { slug }
|
||||
let rank: Int
|
||||
let slug: String
|
||||
let title: String
|
||||
let author: String
|
||||
let cover: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
let sourceURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case rank, slug, title, author, cover, status, genres
|
||||
case sourceURL = "source_url"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
struct ContinueReadingItem: Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let chapter: Int
|
||||
}
|
||||
|
||||
struct HomeStats: Codable {
|
||||
let totalBooks: Int
|
||||
let totalChapters: Int
|
||||
let booksInProgress: Int
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
struct UserSession: Codable, Identifiable {
|
||||
let id: String
|
||||
let userAgent: String
|
||||
let ip: String
|
||||
let createdAt: String
|
||||
let lastSeen: String
|
||||
var isCurrent: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userAgent = "user_agent"
|
||||
case ip
|
||||
case createdAt = "created_at"
|
||||
case lastSeen = "last_seen"
|
||||
case isCurrent = "is_current"
|
||||
}
|
||||
}
|
||||
|
||||
struct PreviewChapter: Codable, Identifiable {
|
||||
var id: Int { number }
|
||||
let number: Int
|
||||
let title: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
struct BookBrief: Codable {
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
struct BookComment: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let slug: String
|
||||
let userId: String
|
||||
let username: String
|
||||
let body: String
|
||||
var upvotes: Int
|
||||
var downvotes: Int
|
||||
let created: String
|
||||
let parentId: String // empty = top-level; non-empty = reply
|
||||
var replies: [BookComment]? // populated client-side from the API response
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, slug, username, body, upvotes, downvotes, created, replies
|
||||
case userId = "user_id"
|
||||
case parentId = "parent_id"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
|
||||
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
|
||||
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
|
||||
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommentsResponse: Decodable {
|
||||
let comments: [BookComment]
|
||||
let myVotes: [String: String]
|
||||
let avatarUrls: [String: String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case comments
|
||||
case myVotes = "myVotes"
|
||||
case avatarUrls = "avatarUrls"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
comments = try c.decode([BookComment].self, forKey: .comments)
|
||||
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
|
||||
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Profile (public)
|
||||
|
||||
struct PublicUserProfile: Decodable, Identifiable {
|
||||
let id: String
|
||||
let username: String
|
||||
let avatarUrl: String?
|
||||
let created: String
|
||||
let followerCount: Int
|
||||
let followingCount: Int
|
||||
let isSubscribed: Bool
|
||||
let isSelf: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, created
|
||||
case avatarUrl = "avatarUrl"
|
||||
case followerCount = "followerCount"
|
||||
case followingCount = "followingCount"
|
||||
case isSubscribed = "isSubscribed"
|
||||
case isSelf = "isSelf"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
username = try c.decode(String.self, forKey: .username)
|
||||
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
|
||||
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
|
||||
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
|
||||
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Feed
|
||||
|
||||
struct SubscriptionFeedItem: Identifiable, Decodable {
|
||||
var id: String { book.id + readerUsername }
|
||||
let book: Book
|
||||
let readerUsername: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case readerUsername = "readerUsername"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public User Library
|
||||
|
||||
struct PublicLibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let lastChapter: Int?
|
||||
let saved: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case lastChapter = "last_chapter"
|
||||
case saved
|
||||
}
|
||||
}
|
||||
|
||||
struct PublicUserLibraryResponse: Decodable {
|
||||
let currentlyReading: [PublicLibraryItem]
|
||||
let library: [PublicLibraryItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentlyReading = "currently_reading"
|
||||
case library
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
enum NextPrefetchStatus {
|
||||
case none, prefetching, prefetched, failed
|
||||
}
|
||||
580
ios/LibNovel/LibNovel/Networking/APIClient.swift
Normal file
580
ios/LibNovel/LibNovel/Networking/APIClient.swift
Normal file
@@ -0,0 +1,580 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Client
|
||||
// Communicates with the SvelteKit UI server (not directly with the Go scraper).
|
||||
// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning.
|
||||
// For the iOS app we talk to the same /api/* endpoints the web UI uses,
|
||||
// so we reuse the exact same HMAC-cookie auth flow.
|
||||
|
||||
actor APIClient {
|
||||
static let shared = APIClient()
|
||||
|
||||
var baseURL: URL
|
||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||
|
||||
// URLSession with persistent cookie storage
|
||||
private let session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
private init() {
|
||||
// Default: point at the UI server. Override via Settings bundle or compile flag.
|
||||
let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
baseURL = URL(string: urlString)!
|
||||
}
|
||||
|
||||
// MARK: - Auth cookie management
|
||||
|
||||
func setAuthCookie(_ value: String?) {
|
||||
authCookie = value
|
||||
if let value {
|
||||
// Also inject into shared cookie storage so redirects carry the cookie
|
||||
let cookieProps: [HTTPCookiePropertyKey: Any] = [
|
||||
.name: "libnovel_auth",
|
||||
.value: value,
|
||||
.domain: baseURL.host ?? "localhost",
|
||||
.path: "/"
|
||||
]
|
||||
if let cookie = HTTPCookie(properties: cookieProps) {
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
} else {
|
||||
// Clear
|
||||
let cookieStorage = HTTPCookieStorage.shared
|
||||
cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Low-level request builder
|
||||
|
||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||
// Build URL by appending the path string directly to the base URL string.
|
||||
// appendingPathComponent() percent-encodes slashes, which breaks multi-segment
|
||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// MARK: - Generic fetch
|
||||
|
||||
func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `fetch` but discards the response body — use for endpoints that return 204 No Content.
|
||||
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
|
||||
let req = try makeRequest(path, method: method, body: body)
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
struct LoginRequest: Encodable {
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let token: String
|
||||
let user: AppUser
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> LoginResponse {
|
||||
try await fetch("/api/auth/login", method: "POST",
|
||||
body: LoginRequest(username: username, password: password))
|
||||
}
|
||||
|
||||
func register(username: String, password: String) async throws -> LoginResponse {
|
||||
try await fetch("/api/auth/register", method: "POST",
|
||||
body: LoginRequest(username: username, password: password))
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||
setAuthCookie(nil)
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
func homeData() async throws -> HomeDataResponse {
|
||||
try await fetch("/api/home")
|
||||
}
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
func library() async throws -> [LibraryItem] {
|
||||
try await fetch("/api/library")
|
||||
}
|
||||
|
||||
func saveBook(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST")
|
||||
}
|
||||
|
||||
func unsaveBook(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Book Detail
|
||||
|
||||
func bookDetail(slug: String) async throws -> BookDetailResponse {
|
||||
try await fetch("/api/book/\(slug)")
|
||||
}
|
||||
|
||||
// MARK: - Chapter
|
||||
|
||||
func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse {
|
||||
try await fetch("/api/chapter/\(slug)/\(chapter)")
|
||||
}
|
||||
|
||||
// MARK: - Browse
|
||||
|
||||
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
||||
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||
return try await fetch("/api/browse-page\(query)")
|
||||
}
|
||||
|
||||
func search(query: String) async throws -> SearchResponse {
|
||||
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
||||
return try await fetch("/api/search?q=\(encoded)")
|
||||
}
|
||||
|
||||
func ranking() async throws -> [RankingItem] {
|
||||
try await fetch("/api/ranking")
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
func progress() async throws -> [ProgressEntry] {
|
||||
try await fetch("/api/progress")
|
||||
}
|
||||
|
||||
func setProgress(slug: String, chapter: Int) async throws {
|
||||
struct Body: Encodable { let chapter: Int }
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
||||
}
|
||||
|
||||
func deleteProgress(slug: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
|
||||
}
|
||||
|
||||
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
|
||||
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||
return r.audioTime
|
||||
}
|
||||
|
||||
func setAudioTime(slug: String, chapter: Int, time: Double) async throws {
|
||||
struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double; enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } }
|
||||
let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time))
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse {
|
||||
struct Body: Encodable { let voice: String; let speed: Double }
|
||||
return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed))
|
||||
}
|
||||
|
||||
/// Poll GET /api/audio/status/{slug}/{n}?voice=... until the job is done or failed.
|
||||
/// Returns the presigned/proxy URL on success, throws on failure or cancellation.
|
||||
func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||
let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)"
|
||||
struct StatusResponse: Decodable {
|
||||
let status: String
|
||||
let url: String?
|
||||
let error: String?
|
||||
}
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let r: StatusResponse = try await fetch(path)
|
||||
switch r.status {
|
||||
case "done":
|
||||
guard let url = r.url, !url.isEmpty else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
return url
|
||||
case "failed":
|
||||
throw NSError(
|
||||
domain: "AudioGeneration",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]
|
||||
)
|
||||
default:
|
||||
// pending / generating / idle — keep polling
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||
struct Response: Decodable { let url: String }
|
||||
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)")
|
||||
return r.url
|
||||
}
|
||||
|
||||
func presignVoiceSample(voice: String) async throws -> String {
|
||||
struct Response: Decodable { let url: String }
|
||||
let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)")
|
||||
return r.url
|
||||
}
|
||||
|
||||
func voices() async throws -> [String] {
|
||||
struct Response: Decodable { let voices: [String] }
|
||||
let r: Response = try await fetch("/api/voices")
|
||||
return r.voices
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func settings() async throws -> UserSettings {
|
||||
try await fetch("/api/settings")
|
||||
}
|
||||
|
||||
func updateSettings(_ settings: UserSettings) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings)
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func sessions() async throws -> [UserSession] {
|
||||
struct Response: Decodable { let sessions: [UserSession] }
|
||||
let r: Response = try await fetch("/api/sessions")
|
||||
return r.sessions
|
||||
}
|
||||
|
||||
func revokeSession(id: String) async throws {
|
||||
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: - Avatar
|
||||
|
||||
struct AvatarPresignResponse: Decodable {
|
||||
let uploadURL: String
|
||||
let key: String
|
||||
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||
}
|
||||
|
||||
struct AvatarResponse: Decodable {
|
||||
let avatarURL: String?
|
||||
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||
}
|
||||
|
||||
/// Upload a profile avatar using a two-step presigned PUT flow:
|
||||
/// 1. POST /api/profile/avatar → get a presigned PUT URL + object key
|
||||
/// 2. PUT image bytes directly to MinIO via the presigned URL
|
||||
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
|
||||
/// Returns the presigned GET URL for the uploaded avatar.
|
||||
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||
// Step 1: request a presigned PUT URL from the SvelteKit server
|
||||
let presign: AvatarPresignResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "POST",
|
||||
body: ["mime_type": mimeType]
|
||||
)
|
||||
|
||||
// Step 2: PUT the image bytes directly to MinIO
|
||||
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||
var putReq = URLRequest(url: putURL)
|
||||
putReq.httpMethod = "PUT"
|
||||
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
putReq.httpBody = imageData
|
||||
|
||||
let (_, putResp) = try await session.data(for: putReq)
|
||||
guard let putHttp = putResp as? HTTPURLResponse,
|
||||
(200..<300).contains(putHttp.statusCode) else {
|
||||
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
throw APIError.httpError(code, "MinIO PUT failed")
|
||||
}
|
||||
|
||||
// Step 3: record the key in PocketBase and get back a presigned GET URL
|
||||
let result: AvatarResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "PATCH",
|
||||
body: ["key": presign.key]
|
||||
)
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
/// Fetches a fresh presigned GET URL for the current user's avatar.
|
||||
/// Returns nil if the user has no avatar set.
|
||||
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
|
||||
func fetchAvatarPresignedURL() async throws -> String? {
|
||||
let result: AvatarResponse = try await fetch("/api/profile/avatar")
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
// MARK: - User Profiles & Subscriptions
|
||||
|
||||
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
|
||||
try await fetch("/api/users/\(username)")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func subscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func unsubscribeUser(username: String) async throws -> Bool {
|
||||
struct Response: Decodable { let subscribed: Bool }
|
||||
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
|
||||
return r.subscribed
|
||||
}
|
||||
|
||||
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
|
||||
try await fetch("/api/users/\(username)/library")
|
||||
}
|
||||
|
||||
// MARK: - Comments
|
||||
|
||||
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||
try await fetch("/api/comments/\(slug)?sort=\(sort)")
|
||||
}
|
||||
|
||||
struct PostCommentBody: Encodable {
|
||||
let body: String
|
||||
let parent_id: String?
|
||||
}
|
||||
|
||||
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||
try await fetch("/api/comments/\(slug)", method: "POST", body: PostCommentBody(body: body, parent_id: parentId))
|
||||
}
|
||||
|
||||
struct VoteBody: Encodable { let vote: String }
|
||||
|
||||
/// Cast, change, or toggle-off a vote on a comment.
|
||||
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
}
|
||||
|
||||
/// Delete a comment (and its replies) by ID. Only the owner can delete.
|
||||
func deleteComment(commentId: String) async throws {
|
||||
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response types
|
||||
|
||||
struct HomeDataResponse: Decodable {
|
||||
struct ContinueItem: Decodable {
|
||||
let book: Book
|
||||
let chapter: Int
|
||||
}
|
||||
let continueReading: [ContinueItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
let subscriptionFeed: [SubscriptionFeedItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case continueReading = "continue_reading"
|
||||
case recentlyUpdated = "recently_updated"
|
||||
case stats
|
||||
case subscriptionFeed = "subscription_feed"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
|
||||
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
|
||||
stats = try c.decode(HomeStats.self, forKey: .stats)
|
||||
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryItem: Decodable, Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
let savedAt: String
|
||||
let lastChapter: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book
|
||||
case savedAt = "saved_at"
|
||||
case lastChapter = "last_chapter"
|
||||
}
|
||||
}
|
||||
|
||||
struct BookDetailResponse: Decodable {
|
||||
let book: Book
|
||||
let chapters: [ChapterIndex]
|
||||
let previewChapters: [PreviewChapter]?
|
||||
let inLib: Bool
|
||||
let saved: Bool
|
||||
let lastChapter: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book, chapters
|
||||
case previewChapters = "preview_chapters"
|
||||
case inLib = "in_lib"
|
||||
case saved
|
||||
case lastChapter = "last_chapter"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChapterResponse: Decodable {
|
||||
let book: BookBrief
|
||||
let chapter: ChapterIndex
|
||||
let html: String
|
||||
let voices: [String]
|
||||
let prev: Int?
|
||||
let next: Int?
|
||||
let chapters: [ChapterIndexBrief]
|
||||
let isPreview: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case book, chapter, html, voices, prev, next, chapters
|
||||
case isPreview = "is_preview"
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowseResponse: Decodable {
|
||||
let novels: [BrowseNovel]
|
||||
let page: Int
|
||||
let hasNext: Bool
|
||||
}
|
||||
|
||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||
var id: String { slug.isEmpty ? url : slug }
|
||||
let slug: String
|
||||
let title: String
|
||||
let cover: String
|
||||
let rank: String
|
||||
let rating: String
|
||||
let chapters: String
|
||||
let url: String
|
||||
let author: String
|
||||
let status: String
|
||||
let genres: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, title, cover, rank, rating, chapters, url, author, status, genres
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||
rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? ""
|
||||
rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? ""
|
||||
chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? ""
|
||||
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||
genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResponse: Decodable {
|
||||
let results: [BrowseNovel]
|
||||
let localCount: Int
|
||||
let remoteCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case results
|
||||
case localCount = "local_count"
|
||||
case remoteCount = "remote_count"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned by POST /api/audio/{slug}/{n}.
|
||||
/// - 202 Accepted: job enqueued → poll via pollAudioStatus()
|
||||
/// - 200 OK: audio already cached → url is ready to play
|
||||
struct AudioTriggerResponse: Decodable {
|
||||
let jobId: String? // present on 202
|
||||
let status: String? // present on 202: "pending" | "generating"
|
||||
let url: String? // present on 200: proxy URL ready to play
|
||||
let filename: String? // present on 200
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobId = "job_id"
|
||||
case status, url, filename
|
||||
}
|
||||
|
||||
/// True when the server accepted the request and created an async job.
|
||||
var isAsync: Bool { jobId != nil }
|
||||
}
|
||||
|
||||
struct ProgressEntry: Decodable, Identifiable {
|
||||
var id: String { slug }
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let audioTime: Double?
|
||||
let updated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, chapter, updated
|
||||
case audioTime = "audio_time"
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyResponse: Decodable {}
|
||||
|
||||
// MARK: - API Error
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(Int, String)
|
||||
case decodingError(Error)
|
||||
case unauthorized
|
||||
case networkError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse: return "Invalid server response"
|
||||
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
|
||||
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
|
||||
case .unauthorized: return "Not authenticated"
|
||||
case .networkError(let e): return e.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSONDecoder helper
|
||||
|
||||
extension JSONDecoder {
|
||||
static let iso8601: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
45
ios/LibNovel/LibNovel/Resources/Info.plist
Normal file
45
ios/LibNovel/LibNovel/Resources/Info.plist
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1000</string>
|
||||
<key>LIBNOVEL_BASE_URL</key>
|
||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
318
ios/LibNovel/LibNovel/Services/AudioDownloadService.swift
Normal file
318
ios/LibNovel/LibNovel/Services/AudioDownloadService.swift
Normal file
@@ -0,0 +1,318 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - AudioDownloadService
|
||||
// Manages offline TTS audio downloads with progress tracking and persistent storage.
|
||||
// Downloads are saved to the app's Documents directory, organized by slug/chapter/voice.
|
||||
|
||||
@MainActor
|
||||
final class AudioDownloadService: NSObject, ObservableObject {
|
||||
static let shared = AudioDownloadService()
|
||||
|
||||
// MARK: - Published State
|
||||
|
||||
@Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice"
|
||||
@Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice"
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var session: URLSession!
|
||||
private var activeTasks: [String: URLSessionDownloadTask] = [:]
|
||||
private let fileManager = FileManager.default
|
||||
private let metadataKey = "downloadedChaptersMetadata"
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
let config = URLSessionConfiguration.background(withIdentifier: "cc.kalekber.libnovel.audio-downloads")
|
||||
config.isDiscretionary = false
|
||||
config.sessionSendsLaunchEvents = true
|
||||
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
loadMetadata()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Check if a chapter's audio is downloaded offline
|
||||
func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
return downloadedChapters.contains(key)
|
||||
}
|
||||
|
||||
/// Get the local file URL for a downloaded chapter (nil if not downloaded)
|
||||
func localURL(slug: String, chapter: Int, voice: String) -> URL? {
|
||||
guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil }
|
||||
return audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
/// Start downloading a chapter's audio
|
||||
func download(slug: String, chapter: Int, voice: String) async throws {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
print("📥 AudioDownload: Starting download - slug: \(slug), chapter: \(chapter), voice: \(voice)")
|
||||
|
||||
// Already downloaded or in progress
|
||||
if downloadedChapters.contains(key) {
|
||||
print("⚠️ AudioDownload: Already downloaded - key: \(key)")
|
||||
return
|
||||
}
|
||||
if activeTasks[key] != nil {
|
||||
print("⚠️ AudioDownload: Already in progress - key: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
// Get presigned URL from API
|
||||
print("🔗 AudioDownload: Fetching presigned URL...")
|
||||
let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice)
|
||||
guard let url = URL(string: urlString) else {
|
||||
print("❌ AudioDownload: Invalid URL - \(urlString)")
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
print("🔗 AudioDownload: Presigned URL obtained: \(url.absoluteString)")
|
||||
|
||||
// Create download task
|
||||
let task = session.downloadTask(with: url)
|
||||
task.taskDescription = key // Use taskDescription to identify the download
|
||||
activeTasks[key] = task
|
||||
|
||||
// Initialize progress tracking
|
||||
downloads[key] = DownloadProgress(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
voice: voice,
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
downloadedBytes: 0,
|
||||
status: .downloading
|
||||
)
|
||||
|
||||
print("🚀 AudioDownload: Starting download task - key: \(key)")
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/// Cancel an ongoing download
|
||||
func cancelDownload(slug: String, chapter: Int, voice: String) {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
activeTasks[key]?.cancel()
|
||||
activeTasks.removeValue(forKey: key)
|
||||
downloads.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
/// Delete a downloaded chapter
|
||||
func deleteDownload(slug: String, chapter: Int, voice: String) throws {
|
||||
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
downloadedChapters.remove(key)
|
||||
downloads.removeValue(forKey: key)
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
/// Get total storage used by downloads (in bytes)
|
||||
func getTotalStorageUsed() -> Int64 {
|
||||
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let audioDir = documentsURL.appendingPathComponent("audio")
|
||||
guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||
return 0
|
||||
}
|
||||
|
||||
var totalSize: Int64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
totalSize += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
|
||||
/// Delete all downloads
|
||||
func deleteAllDownloads() throws {
|
||||
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
return
|
||||
}
|
||||
|
||||
let audioDir = documentsURL.appendingPathComponent("audio")
|
||||
if fileManager.fileExists(atPath: audioDir.path) {
|
||||
try fileManager.removeItem(at: audioDir)
|
||||
}
|
||||
|
||||
downloadedChapters.removeAll()
|
||||
downloads.removeAll()
|
||||
activeTasks.values.forEach { $0.cancel() }
|
||||
activeTasks.removeAll()
|
||||
saveMetadata()
|
||||
}
|
||||
|
||||
/// Get list of all book slugs that have offline downloads
|
||||
func getOfflineBookSlugs() -> [String] {
|
||||
let slugs = downloadedChapters.compactMap { key -> String? in
|
||||
let components = key.split(separator: "::")
|
||||
guard components.count == 3 else { return nil }
|
||||
return String(components[0])
|
||||
}
|
||||
return Array(Set(slugs)).sorted()
|
||||
}
|
||||
|
||||
/// Get count of downloaded chapters for a specific book
|
||||
func getDownloadedChapterCount(for slug: String) -> Int {
|
||||
return downloadedChapters.filter { key in
|
||||
let components = key.split(separator: "::")
|
||||
guard components.count == 3 else { return false }
|
||||
return String(components[0]) == slug
|
||||
}.count
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Build the canonical download key used for both in-memory tracking and UserDefaults.
|
||||
/// Uses `::` as separator so slugs that contain `-` are unambiguous.
|
||||
func makeKey(slug: String, chapter: Int, voice: String) -> String {
|
||||
"\(slug)::\(chapter)::\(voice)"
|
||||
}
|
||||
|
||||
nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL {
|
||||
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||
fatalError("Could not access documents directory")
|
||||
}
|
||||
|
||||
return documentsURL
|
||||
.appendingPathComponent("audio")
|
||||
.appendingPathComponent(slug)
|
||||
.appendingPathComponent("\(chapter)-\(voice).mp3")
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
if let data = UserDefaults.standard.data(forKey: metadataKey),
|
||||
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||
downloadedChapters = decoded
|
||||
}
|
||||
}
|
||||
|
||||
private func saveMetadata() {
|
||||
if let encoded = try? JSONEncoder().encode(downloadedChapters) {
|
||||
UserDefaults.standard.set(encoded, forKey: metadataKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDownloadDelegate
|
||||
|
||||
extension AudioDownloadService: URLSessionDownloadDelegate {
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
guard let key = downloadTask.taskDescription else {
|
||||
print("⚠️ AudioDownload: No task description")
|
||||
return
|
||||
}
|
||||
|
||||
print("✅ AudioDownload: Finished downloading - key: \(key)")
|
||||
|
||||
let components = key.split(separator: "::")
|
||||
guard components.count == 3,
|
||||
let chapter = Int(components[1]) else {
|
||||
print("⚠️ AudioDownload: Invalid key format: \(key)")
|
||||
return
|
||||
}
|
||||
|
||||
let slug = String(components[0])
|
||||
let voice = String(components[2])
|
||||
|
||||
let destinationURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||
|
||||
print("📁 AudioDownload: Moving from \(location.path) to \(destinationURL.path)")
|
||||
|
||||
do {
|
||||
// Create directory if needed
|
||||
let directory = destinationURL.deletingLastPathComponent()
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
// Move file from temp location to permanent storage
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
print("📁 AudioDownload: Removing existing file at destination")
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
try fileManager.moveItem(at: location, to: destinationURL)
|
||||
|
||||
print("✅ AudioDownload: File moved successfully")
|
||||
|
||||
Task { @MainActor in
|
||||
print("✅ AudioDownload: Marking as completed - key: \(key)")
|
||||
self.downloadedChapters.insert(key)
|
||||
self.downloads.removeValue(forKey: key) // Remove from active downloads
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
self.saveMetadata()
|
||||
print("✅ AudioDownload: Metadata saved, downloadedChapters count: \(self.downloadedChapters.count)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ AudioDownload: Failed to move file - \(error.localizedDescription)")
|
||||
Task { @MainActor in
|
||||
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
guard let key = downloadTask.taskDescription else { return }
|
||||
|
||||
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||||
|
||||
if Int(progress * 100) % 10 == 0 { // Log every 10%
|
||||
print("📊 AudioDownload: Progress for \(key): \(Int(progress * 100))% (\(totalBytesWritten)/\(totalBytesExpectedToWrite) bytes)")
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
if var progressData = self.downloads[key] {
|
||||
progressData.downloadedBytes = totalBytesWritten
|
||||
progressData.totalBytes = totalBytesExpectedToWrite
|
||||
progressData.progress = progress
|
||||
self.downloads[key] = progressData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let key = task.taskDescription else { return }
|
||||
|
||||
if let error = error {
|
||||
let nsError = error as NSError
|
||||
if nsError.code != NSURLErrorCancelled {
|
||||
print("❌ AudioDownload: Task completed with error - key: \(key), error: \(error.localizedDescription)")
|
||||
Task { @MainActor in
|
||||
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||
self.activeTasks.removeValue(forKey: key)
|
||||
}
|
||||
} else {
|
||||
print("⚠️ AudioDownload: Task cancelled - key: \(key)")
|
||||
}
|
||||
} else {
|
||||
print("✅ AudioDownload: Task completed without error - key: \(key)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
struct DownloadProgress: Equatable {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let voice: String
|
||||
var progress: Double
|
||||
var totalBytes: Int64
|
||||
var downloadedBytes: Int64
|
||||
var status: DownloadStatus
|
||||
}
|
||||
|
||||
enum DownloadStatus: Equatable {
|
||||
case downloading
|
||||
case completed
|
||||
case failed(String)
|
||||
}
|
||||
627
ios/LibNovel/LibNovel/Services/AudioPlayerService.swift
Normal file
627
ios/LibNovel/LibNovel/Services/AudioPlayerService.swift
Normal file
@@ -0,0 +1,627 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - PlaybackProgress
|
||||
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||
// duration, isPlaying). Keeping these separate from AudioPlayerService means
|
||||
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||
// observe PlaybackProgress — menus and other stable UI are unaffected.
|
||||
|
||||
@MainActor
|
||||
final class PlaybackProgress: ObservableObject {
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var isPlaying: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - AudioPlayerService
|
||||
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
|
||||
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
|
||||
// next chapter audio.
|
||||
|
||||
@MainActor
|
||||
final class AudioPlayerService: ObservableObject {
|
||||
|
||||
// MARK: - Published state
|
||||
|
||||
@Published var slug: String = ""
|
||||
@Published var chapter: Int = 0
|
||||
@Published var chapterTitle: String = ""
|
||||
@Published var bookTitle: String = ""
|
||||
@Published var coverURL: String = ""
|
||||
@Published var voice: String = "af_bella"
|
||||
@Published var speed: Double = 1.0
|
||||
@Published var chapters: [ChapterIndexBrief] = []
|
||||
|
||||
@Published var status: AudioPlayerStatus = .idle
|
||||
@Published var audioURL: String = ""
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var generationProgress: Double = 0
|
||||
|
||||
/// High-frequency playback state (currentTime / duration / isPlaying).
|
||||
/// Views that only need the seek bar or play-pause button should observe
|
||||
/// this directly so they don't trigger re-renders of menu-bearing parents.
|
||||
let progress = PlaybackProgress()
|
||||
|
||||
// Convenience forwarders so non-view call sites keep compiling unchanged.
|
||||
var currentTime: Double {
|
||||
get { progress.currentTime }
|
||||
set { progress.currentTime = newValue }
|
||||
}
|
||||
var duration: Double {
|
||||
get { progress.duration }
|
||||
set { progress.duration = newValue }
|
||||
}
|
||||
var isPlaying: Bool {
|
||||
get { progress.isPlaying }
|
||||
set { progress.isPlaying = newValue }
|
||||
}
|
||||
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
/// Human-readable countdown string shown in the full player near the moon button.
|
||||
/// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off.
|
||||
@Published var sleepTimerRemainingText: String = ""
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@Published var nextPrefetchedChapter: Int? = nil
|
||||
|
||||
var isActive: Bool {
|
||||
switch status {
|
||||
case .idle: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var playerItem: AVPlayerItem?
|
||||
private var timeObserver: Any?
|
||||
private var statusObserver: AnyCancellable?
|
||||
private var durationObserver: AnyCancellable?
|
||||
private var finishObserver: AnyCancellable?
|
||||
private var generationTask: Task<Void, Never>?
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
// Cached cover image — downloaded once per chapter load, reused on every
|
||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
private var cachedCoverURL: String = ""
|
||||
|
||||
// Sleep timer tracking
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
|
||||
private var sleepTimerDeadline: Date? = nil
|
||||
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
|
||||
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
configureAudioSession()
|
||||
setupRemoteCommandCenter()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
|
||||
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||
generationTask?.cancel()
|
||||
prefetchTask?.cancel()
|
||||
stop()
|
||||
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
self.chapterTitle = chapterTitle
|
||||
self.bookTitle = bookTitle
|
||||
self.coverURL = coverURL
|
||||
self.voice = voice
|
||||
self.speed = speed
|
||||
self.chapters = chapters
|
||||
self.nextChapter = nextChapter
|
||||
self.prevChapter = prevChapter
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
|
||||
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = chapter
|
||||
}
|
||||
|
||||
status = .generating
|
||||
generationProgress = 0
|
||||
|
||||
// Invalidate cover cache if the book changed.
|
||||
if coverURL != cachedCoverURL {
|
||||
cachedCoverArtwork = nil
|
||||
cachedCoverURL = coverURL
|
||||
prefetchCoverArtwork(from: coverURL)
|
||||
}
|
||||
|
||||
generationTask = Task { await generateAudio() }
|
||||
}
|
||||
|
||||
func play() {
|
||||
player?.play()
|
||||
player?.rate = Float(speed)
|
||||
isPlaying = true
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
let time = CMTime(seconds: seconds, preferredTimescale: 600)
|
||||
currentTime = seconds // optimistic UI update
|
||||
player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.updateNowPlaying() }
|
||||
}
|
||||
}
|
||||
|
||||
func skip(by seconds: Double) {
|
||||
seek(to: max(0, min(currentTime + seconds, duration)))
|
||||
}
|
||||
|
||||
func setSpeed(_ newSpeed: Double) {
|
||||
speed = newSpeed
|
||||
if isPlaying { player?.rate = Float(newSpeed) }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
// Cancel existing timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else {
|
||||
sleepTimerRemainingText = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Start timer based on option
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
// Update display immediately; chapter changes are tracked in handlePlaybackFinished.
|
||||
updateChapterTimerLabel(chaptersRemaining: count)
|
||||
|
||||
case .minutes(let minutes):
|
||||
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||
sleepTimerDeadline = deadline
|
||||
// Stop playback when the deadline is reached.
|
||||
sleepTimerTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.stop()
|
||||
self.sleepTimer = nil
|
||||
self.sleepTimerRemainingText = ""
|
||||
}
|
||||
}
|
||||
// 1-second tick to keep the countdown label fresh.
|
||||
sleepTimerCountdownTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let deadline = self.sleepTimerDeadline else { return }
|
||||
let remaining = max(0, deadline.timeIntervalSinceNow)
|
||||
self.sleepTimerRemainingText = Self.formatCountdown(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set initial label without waiting for the first tick.
|
||||
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateChapterTimerLabel(chaptersRemaining: Int) {
|
||||
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
|
||||
}
|
||||
|
||||
private static func formatCountdown(_ seconds: Double) -> String {
|
||||
let s = Int(max(0, seconds))
|
||||
let m = s / 60
|
||||
let sec = s % 60
|
||||
return "\(m):\(String(format: "%02d", sec))"
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
teardownPlayer()
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
|
||||
// Cancel sleep timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
sleepTimer = nil
|
||||
sleepTimerRemainingText = ""
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
|
||||
private func generateAudio() async {
|
||||
guard !slug.isEmpty, chapter > 0 else { return }
|
||||
|
||||
// Check if audio is downloaded locally first
|
||||
if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) {
|
||||
audioURL = localURL.absoluteString
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(localURL.absoluteString)
|
||||
await prefetchNext()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// Fast path: audio already in MinIO — get a presigned URL and play immediately.
|
||||
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
|
||||
audioURL = presignedURL
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(presignedURL)
|
||||
await prefetchNext()
|
||||
return
|
||||
}
|
||||
|
||||
// Slow path: trigger TTS generation (async — returns 202 immediately).
|
||||
status = .generating
|
||||
generationProgress = 10
|
||||
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
|
||||
|
||||
let playableURL: String
|
||||
if trigger.isAsync {
|
||||
// 202 Accepted: poll until done.
|
||||
generationProgress = 30
|
||||
playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice)
|
||||
} else {
|
||||
// 200: already cached URL returned inline.
|
||||
guard let url = trigger.url, !url.isEmpty else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
playableURL = url
|
||||
}
|
||||
|
||||
audioURL = playableURL
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
await playURL(playableURL)
|
||||
await prefetchNext()
|
||||
} catch is CancellationError {
|
||||
// Cancelled — no-op
|
||||
} catch {
|
||||
status = .error(error.localizedDescription)
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Prefetch next chapter
|
||||
// Always prefetch regardless of autoNext — faster playback when the user
|
||||
// manually navigates forward. autoNext only controls whether we auto-navigate.
|
||||
|
||||
private func prefetchNext() async {
|
||||
guard let next = nextChapter, !Task.isCancelled else { return }
|
||||
nextPrefetchStatus = .prefetching
|
||||
nextPrefetchedChapter = next
|
||||
do {
|
||||
// Fast path: already in MinIO.
|
||||
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) {
|
||||
nextAudioURL = presignedURL
|
||||
nextPrefetchStatus = .prefetched
|
||||
return
|
||||
}
|
||||
// Slow path: trigger generation; poll until done (background — won't block playback).
|
||||
let trigger = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
|
||||
let url: String
|
||||
if trigger.isAsync {
|
||||
url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice)
|
||||
} else {
|
||||
guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) }
|
||||
url = u
|
||||
}
|
||||
nextAudioURL = url
|
||||
nextPrefetchStatus = .prefetched
|
||||
} catch {
|
||||
nextPrefetchStatus = .failed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayer management
|
||||
|
||||
private func playURL(_ urlString: String) async {
|
||||
// Resolve relative paths (e.g. "/api/audio/...") to absolute URLs.
|
||||
let resolved: URL?
|
||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||
resolved = URL(string: urlString)
|
||||
} else {
|
||||
resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL
|
||||
}
|
||||
guard let url = resolved else { return }
|
||||
teardownPlayer()
|
||||
let item = AVPlayerItem(url: url)
|
||||
playerItem = item
|
||||
player = AVPlayer(playerItem: item)
|
||||
|
||||
// KVO: update duration as soon as asset metadata is loaded.
|
||||
durationObserver = item.publisher(for: \.duration)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] dur in
|
||||
guard let self else { return }
|
||||
let secs = dur.seconds
|
||||
if secs.isFinite && secs > 0 {
|
||||
self.duration = secs
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
// KVO: set playback rate once the item is ready.
|
||||
// Do NOT call player?.play() unconditionally — let readyToPlay trigger it
|
||||
// so we don't race between AVPlayer's internal buffering and our call.
|
||||
statusObserver = item.publisher(for: \.status)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] itemStatus in
|
||||
guard let self else { return }
|
||||
if itemStatus == .readyToPlay {
|
||||
self.player?.rate = Float(self.speed)
|
||||
self.isPlaying = true
|
||||
self.updateNowPlaying()
|
||||
} else if itemStatus == .failed {
|
||||
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
|
||||
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic time observer for seek bar position.
|
||||
timeObserver = player?.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
|
||||
queue: .main
|
||||
) { [weak self] time in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let secs = time.seconds
|
||||
if secs.isFinite && secs >= 0 {
|
||||
self.currentTime = secs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe when playback ends.
|
||||
finishObserver = NotificationCenter.default
|
||||
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
|
||||
.sink { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.handlePlaybackFinished()
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off buffering — actual playback starts via statusObserver above.
|
||||
player?.play()
|
||||
}
|
||||
|
||||
private func teardownPlayer() {
|
||||
if let observer = timeObserver { player?.removeTimeObserver(observer) }
|
||||
timeObserver = nil
|
||||
statusObserver = nil
|
||||
durationObserver = nil
|
||||
finishObserver = nil
|
||||
player = nil
|
||||
playerItem = nil
|
||||
}
|
||||
|
||||
private func handlePlaybackFinished() {
|
||||
isPlaying = false
|
||||
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Check chapter-based sleep timer
|
||||
if case .chapters(let count) = sleepTimer {
|
||||
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||
if chaptersPlayed >= count {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
// Update the remaining chapters label.
|
||||
let remaining = count - chaptersPlayed
|
||||
updateChapterTimerLabel(chaptersRemaining: remaining)
|
||||
}
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
NotificationCenter.default.post(
|
||||
name: .audioDidFinishChapter,
|
||||
object: nil,
|
||||
userInfo: ["next": next, "autoNext": autoNext]
|
||||
)
|
||||
|
||||
// If autoNext is on, load the next chapter internally right away.
|
||||
// We already have the metadata in `chapters`, so we can reconstruct
|
||||
// everything without waiting for the view to navigate.
|
||||
guard autoNext else { return }
|
||||
|
||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
|
||||
|
||||
// If we already prefetched a URL for the next chapter, skip straight to
|
||||
// playback and kick off generation in the background for the one after.
|
||||
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
|
||||
let url = nextAudioURL
|
||||
|
||||
// Advance state before tearing down the current player.
|
||||
chapter = next
|
||||
chapterTitle = nextTitle
|
||||
nextChapter = nextNextChapter
|
||||
prevChapter = nextPrevChapter
|
||||
nextPrefetchStatus = .none
|
||||
nextAudioURL = ""
|
||||
nextPrefetchedChapter = nil
|
||||
audioURL = url
|
||||
status = .ready
|
||||
generationProgress = 100
|
||||
|
||||
// Update sleep timer start chapter if using chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = next
|
||||
}
|
||||
|
||||
generationTask = Task {
|
||||
await playURL(url)
|
||||
await prefetchNext()
|
||||
}
|
||||
} else {
|
||||
// No prefetch available — do a full load.
|
||||
load(
|
||||
slug: slug,
|
||||
chapter: next,
|
||||
chapterTitle: nextTitle,
|
||||
bookTitle: bookTitle,
|
||||
coverURL: coverURL,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: nextNextChapter,
|
||||
prevChapter: nextPrevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover art prefetch
|
||||
|
||||
private func prefetchCoverArtwork(from urlString: String) {
|
||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
|
||||
guard let self else { return }
|
||||
if case .success(let value) = result {
|
||||
let image = value.image
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
Task { @MainActor in
|
||||
self.cachedCoverArtwork = artwork
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Session
|
||||
|
||||
private func configureAudioSession() {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen / Control Center
|
||||
|
||||
private func setupRemoteCommandCenter() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.addTarget { [weak self] _ in
|
||||
self?.play()
|
||||
return .success
|
||||
}
|
||||
center.pauseCommand.addTarget { [weak self] _ in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
center.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
self?.togglePlayPause()
|
||||
return .success
|
||||
}
|
||||
center.skipForwardCommand.preferredIntervals = [15]
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.skip(by: 15)
|
||||
return .success
|
||||
}
|
||||
center.skipBackwardCommand.preferredIntervals = [15]
|
||||
center.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
self?.skip(by: -15)
|
||||
return .success
|
||||
}
|
||||
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
if let e = event as? MPChangePlaybackPositionCommandEvent {
|
||||
self?.seek(to: e.positionTime)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle,
|
||||
MPMediaItemPropertyArtist: bookTitle,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
||||
MPMediaItemPropertyPlaybackDuration: duration,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0
|
||||
]
|
||||
// Use cached artwork — downloaded once in prefetchCoverArtwork().
|
||||
if let artwork = cachedCoverArtwork {
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting types
|
||||
|
||||
enum AudioPlayerStatus: Equatable {
|
||||
case idle
|
||||
case generating // covers both "loading" and "generating TTS" phases
|
||||
case ready
|
||||
case error(String)
|
||||
|
||||
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle), (.generating, .generating), (.ready, .ready):
|
||||
return true
|
||||
case (.error(let a), .error(let b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case chapters(Int) // Stop after N chapters
|
||||
case minutes(Int) // Stop after N minutes
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
|
||||
}
|
||||
159
ios/LibNovel/LibNovel/Services/AuthStore.swift
Normal file
159
ios/LibNovel/LibNovel/Services/AuthStore.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - AuthStore
|
||||
// Owns the authenticated user, the HMAC auth token, and user settings.
|
||||
// Persists the token to Keychain so the user stays logged in across launches.
|
||||
|
||||
@MainActor
|
||||
final class AuthStore: ObservableObject {
|
||||
@Published var user: AppUser?
|
||||
@Published var settings: UserSettings = .default
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var error: String?
|
||||
|
||||
var isAuthenticated: Bool { user != nil }
|
||||
|
||||
private let keychainKey = "libnovel_auth_token"
|
||||
|
||||
init() {
|
||||
// Restore token from Keychain and validate it on launch
|
||||
if let token = loadToken() {
|
||||
Task { await validateToken(token) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login / Register
|
||||
|
||||
func login(username: String, password: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.login(username: username, password: password)
|
||||
await APIClient.shared.setAuthCookie(response.token)
|
||||
saveToken(response.token)
|
||||
user = response.user
|
||||
await loadSettings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func register(username: String, password: String) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.register(username: username, password: password)
|
||||
await APIClient.shared.setAuthCookie(response.token)
|
||||
saveToken(response.token)
|
||||
user = response.user
|
||||
await loadSettings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
do {
|
||||
try await APIClient.shared.logout()
|
||||
} catch {
|
||||
// Best-effort; clear local state regardless
|
||||
}
|
||||
clearToken()
|
||||
user = nil
|
||||
settings = .default
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
func loadSettings() async {
|
||||
do {
|
||||
settings = try await APIClient.shared.settings()
|
||||
} catch {
|
||||
// Use defaults if settings endpoint fails
|
||||
}
|
||||
}
|
||||
|
||||
func saveSettings(_ updated: UserSettings) async {
|
||||
do {
|
||||
try await APIClient.shared.updateSettings(updated)
|
||||
settings = updated
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token validation
|
||||
|
||||
/// Re-validates the current session and refreshes `user` + `settings`.
|
||||
/// Call this after any operation that may change the user record (e.g. avatar upload).
|
||||
func validateToken() async {
|
||||
guard let token = loadToken() else { return }
|
||||
await validateToken(token)
|
||||
}
|
||||
|
||||
private func validateToken(_ token: String) async {
|
||||
await APIClient.shared.setAuthCookie(token)
|
||||
// Use /api/auth/me to restore the user record and confirm the token is still valid
|
||||
do {
|
||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||
async let s: UserSettings = APIClient.shared.settings()
|
||||
var (restoredUser, restoredSettings) = try await (me, s)
|
||||
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
|
||||
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
|
||||
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
||||
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
||||
restoredUser = AppUser(
|
||||
id: restoredUser.id,
|
||||
username: restoredUser.username,
|
||||
role: restoredUser.role,
|
||||
created: restoredUser.created,
|
||||
avatarURL: presignedURL
|
||||
)
|
||||
}
|
||||
}
|
||||
user = restoredUser
|
||||
settings = restoredSettings
|
||||
} catch let e as APIError {
|
||||
if case .httpError(let code, _) = e, code == 401 {
|
||||
clearToken()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// MARK: - Keychain helpers
|
||||
|
||||
private func saveToken(_ token: String) {
|
||||
let data = Data(token.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func loadToken() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func clearToken() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: keychainKey
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
73
ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift
Normal file
73
ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Book Voice Preferences Service
|
||||
// Manages per-book voice overrides with global fallback
|
||||
|
||||
@MainActor
|
||||
final class BookVoicePreferences: ObservableObject {
|
||||
static let shared = BookVoicePreferences()
|
||||
|
||||
@Published private(set) var bookVoices: [String: String] = [:] // slug -> voice
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let storageKey = "bookVoicePreferences"
|
||||
|
||||
private init() {
|
||||
loadPreferences()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Get the voice for a specific book (returns nil if no override set)
|
||||
func voice(for slug: String) -> String? {
|
||||
return bookVoices[slug]
|
||||
}
|
||||
|
||||
/// Get the voice for a book with fallback to global user voice
|
||||
func voiceWithFallback(for slug: String, globalVoice: String) -> String {
|
||||
return bookVoices[slug] ?? globalVoice
|
||||
}
|
||||
|
||||
/// Set a voice override for a specific book
|
||||
func setVoice(_ voice: String, for slug: String) {
|
||||
print("📚 BookVoicePreferences: Setting voice '\(voice)' for book '\(slug)'")
|
||||
bookVoices[slug] = voice
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
/// Remove voice override for a book (will use global voice)
|
||||
func removeVoice(for slug: String) {
|
||||
print("📚 BookVoicePreferences: Removing voice override for book '\(slug)'")
|
||||
bookVoices.removeValue(forKey: slug)
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
/// Check if a book has a voice override
|
||||
func hasOverride(for slug: String) -> Bool {
|
||||
return bookVoices[slug] != nil
|
||||
}
|
||||
|
||||
/// Clear all book voice overrides
|
||||
func clearAll() {
|
||||
print("📚 BookVoicePreferences: Clearing all book voice overrides")
|
||||
bookVoices.removeAll()
|
||||
savePreferences()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func loadPreferences() {
|
||||
if let data = userDefaults.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||
bookVoices = decoded
|
||||
print("📚 BookVoicePreferences: Loaded \(bookVoices.count) book voice overrides")
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
if let encoded = try? JSONEncoder().encode(bookVoices) {
|
||||
userDefaults.set(encoded, forKey: storageKey)
|
||||
print("📚 BookVoicePreferences: Saved \(bookVoices.count) book voice overrides")
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios/LibNovel/LibNovel/Services/NetworkMonitor.swift
Normal file
54
ios/LibNovel/LibNovel/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
// MARK: - Network Monitor
|
||||
// Monitors network connectivity and provides offline state across the app
|
||||
|
||||
@MainActor
|
||||
final class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
|
||||
@Published var isConnected: Bool = true
|
||||
@Published var connectionType: NWInterface.InterfaceType?
|
||||
|
||||
private let monitor: NWPathMonitor
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
|
||||
init() {
|
||||
monitor = NWPathMonitor()
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isConnected = path.status == .satisfied
|
||||
self?.connectionType = path.availableInterfaces.first?.type
|
||||
|
||||
if path.status == .satisfied {
|
||||
print("🌐 Network: Connected (\(path.availableInterfaces.first?.type.debugDescription ?? "unknown"))")
|
||||
} else {
|
||||
print("📴 Network: Offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension NWInterface.InterfaceType {
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .wifi: return "Wi-Fi"
|
||||
case .cellular: return "Cellular"
|
||||
case .wiredEthernet: return "Ethernet"
|
||||
case .loopback: return "Loopback"
|
||||
case .other: return "Other"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift
Normal file
47
ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class BookDetailViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var book: Book?
|
||||
@Published var chapters: [ChapterIndex] = []
|
||||
@Published var saved: Bool = false
|
||||
@Published var lastChapter: Int?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let detail = try await APIClient.shared.bookDetail(slug: slug)
|
||||
book = detail.book
|
||||
chapters = detail.chapters
|
||||
saved = detail.saved
|
||||
lastChapter = detail.lastChapter
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSaved() async {
|
||||
do {
|
||||
if saved {
|
||||
try await APIClient.shared.unsaveBook(slug: slug)
|
||||
} else {
|
||||
try await APIClient.shared.saveBook(slug: slug)
|
||||
}
|
||||
saved.toggle()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
73
ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift
Normal file
73
ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class BrowseViewModel: ObservableObject {
|
||||
@Published var novels: [BrowseNovel] = []
|
||||
@Published var sort: String = "popular"
|
||||
@Published var genre: String = "all"
|
||||
@Published var status: String = "all"
|
||||
@Published var searchQuery: String = ""
|
||||
@Published var isLoading = false
|
||||
@Published var hasNext = false
|
||||
@Published var error: String?
|
||||
|
||||
private var currentPage = 1
|
||||
private var isSearchMode = false
|
||||
|
||||
func loadFirstPage() async {
|
||||
currentPage = 1
|
||||
novels = []
|
||||
isSearchMode = false
|
||||
await loadPage(1)
|
||||
}
|
||||
|
||||
func loadNextPage() async {
|
||||
guard hasNext, !isLoading else { return }
|
||||
await loadPage(currentPage + 1)
|
||||
}
|
||||
|
||||
func search() async {
|
||||
guard !searchQuery.isEmpty else { await loadFirstPage(); return }
|
||||
isLoading = true
|
||||
isSearchMode = true
|
||||
novels = []
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.search(query: searchQuery)
|
||||
novels = result.results
|
||||
hasNext = false
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func clearSearch() {
|
||||
searchQuery = ""
|
||||
Task { await loadFirstPage() }
|
||||
}
|
||||
|
||||
private func loadPage(_ page: Int) async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(
|
||||
page: page, genre: genre, sort: sort, status: status
|
||||
)
|
||||
if page == 1 {
|
||||
novels = result.novels
|
||||
} else {
|
||||
novels.append(contentsOf: result.novels)
|
||||
}
|
||||
hasNext = result.hasNext
|
||||
currentPage = page
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ChapterReaderViewModel: ObservableObject {
|
||||
let slug: String
|
||||
private(set) var chapter: Int
|
||||
|
||||
@Published var content: ChapterResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
init(slug: String, chapter: Int) {
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
/// Switch to a different chapter in-place: resets state and updates `chapter`
|
||||
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
|
||||
func switchChapter(to newChapter: Int) {
|
||||
guard newChapter != chapter else { return }
|
||||
chapter = newChapter
|
||||
content = nil
|
||||
error = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||
// Record reading progress
|
||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
|
||||
guard let content else { return }
|
||||
|
||||
// Only treat as "current" if the player is active (not idle/stopped).
|
||||
// If the user stopped playback, isActive is false — we must re-load.
|
||||
let isCurrent = audioPlayer.isActive &&
|
||||
audioPlayer.slug == slug &&
|
||||
audioPlayer.chapter == chapter
|
||||
|
||||
if isCurrent {
|
||||
audioPlayer.togglePlayPause()
|
||||
} else {
|
||||
let nextChapter: Int? = content.next
|
||||
let prevChapter: Int? = content.prev
|
||||
|
||||
// Use per-book voice override, fallback to global voice
|
||||
let voice = BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: settings.voice)
|
||||
|
||||
audioPlayer.load(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
chapterTitle: content.chapter.title,
|
||||
bookTitle: content.book.title,
|
||||
coverURL: content.book.cover,
|
||||
voice: voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
nextChapter: nextChapter,
|
||||
prevChapter: prevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift
Normal file
78
ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class DiscoverViewModel: ObservableObject {
|
||||
@Published var trending: [BrowseNovel] = []
|
||||
@Published var topRated: [BrowseNovel] = []
|
||||
@Published var recentlyUpdated: [BrowseNovel] = []
|
||||
@Published var newReleases: [BrowseNovel] = []
|
||||
@Published var genreShelves: [GenreShelf] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
struct GenreShelf: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let genre: String
|
||||
var novels: [BrowseNovel] = []
|
||||
}
|
||||
|
||||
// Popular genres to show as shelves
|
||||
private let featuredGenres = [
|
||||
("fantasy", "Fantasy"),
|
||||
("romance", "Romance"),
|
||||
("action", "Action"),
|
||||
("sci-fi", "Sci-Fi"),
|
||||
("mystery", "Mystery")
|
||||
]
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
async let trendingTask = loadShelf(sort: "popular", limit: 20)
|
||||
async let topRatedTask = loadShelf(sort: "rating", limit: 20)
|
||||
async let recentlyUpdatedTask = loadShelf(sort: "updated", limit: 20)
|
||||
async let newReleasesTask = loadShelf(sort: "new", limit: 20)
|
||||
|
||||
do {
|
||||
trending = try await trendingTask
|
||||
topRated = try await topRatedTask
|
||||
recentlyUpdated = try await recentlyUpdatedTask
|
||||
newReleases = try await newReleasesTask
|
||||
|
||||
// Load genre shelves
|
||||
await loadGenreShelves()
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadShelf(sort: String, genre: String = "all", status: String = "all", limit: Int = 20) async throws -> [BrowseNovel] {
|
||||
let result = try await APIClient.shared.browse(page: 1, genre: genre, sort: sort, status: status)
|
||||
return Array(result.novels.prefix(limit))
|
||||
}
|
||||
|
||||
private func loadGenreShelves() async {
|
||||
var shelves: [GenreShelf] = []
|
||||
|
||||
for (genre, name) in featuredGenres {
|
||||
do {
|
||||
let novels = try await loadShelf(sort: "popular", genre: genre, limit: 15)
|
||||
if !novels.isEmpty {
|
||||
shelves.append(GenreShelf(id: genre, name: name, genre: genre, novels: novels))
|
||||
}
|
||||
} catch {
|
||||
// Skip failed genres silently
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
genreShelves = shelves
|
||||
}
|
||||
}
|
||||
30
ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift
Normal file
30
ios/LibNovel/LibNovel/ViewModels/HomeViewModel.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class HomeViewModel: ObservableObject {
|
||||
@Published var continueReading: [ContinueReadingItem] = []
|
||||
@Published var recentlyUpdated: [Book] = []
|
||||
@Published var stats: HomeStats?
|
||||
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let data = try await APIClient.shared.homeData()
|
||||
continueReading = data.continueReading.map {
|
||||
ContinueReadingItem(book: $0.book, chapter: $0.chapter)
|
||||
}
|
||||
recentlyUpdated = data.recentlyUpdated
|
||||
stats = data.stats
|
||||
subscriptionFeed = data.subscriptionFeed
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
21
ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift
Normal file
21
ios/LibNovel/LibNovel/ViewModels/LibraryViewModel.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LibraryViewModel: ObservableObject {
|
||||
@Published var items: [LibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
items = try await APIClient.shared.library()
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
40
ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift
Normal file
40
ios/LibNovel/LibNovel/ViewModels/ProfileViewModel.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ProfileViewModel: ObservableObject {
|
||||
@Published var sessions: [UserSession] = []
|
||||
@Published var voices: [String] = []
|
||||
@Published var sessionsLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
func loadSessions() async {
|
||||
sessionsLoading = true
|
||||
do {
|
||||
sessions = try await APIClient.shared.sessions()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
sessionsLoading = false
|
||||
}
|
||||
|
||||
func loadVoices() async {
|
||||
guard voices.isEmpty else { return }
|
||||
do {
|
||||
voices = try await APIClient.shared.voices()
|
||||
} catch {
|
||||
// Use hardcoded fallback — same as Go server helpers.go
|
||||
voices = ["af_bella", "af_sky", "af_sarah", "af_nicole",
|
||||
"am_adam", "am_michael", "bf_emma", "bf_isabella",
|
||||
"bm_george", "bm_lewis"]
|
||||
}
|
||||
}
|
||||
|
||||
func revokeSession(id: String) async {
|
||||
do {
|
||||
try await APIClient.shared.revokeSession(id: id)
|
||||
sessions.removeAll { $0.id == id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class UserProfileViewModel: ObservableObject {
|
||||
let username: String
|
||||
|
||||
@Published var profile: PublicUserProfile?
|
||||
@Published var currentlyReading: [PublicLibraryItem] = []
|
||||
@Published var library: [PublicLibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isTogglingSubscribe = false
|
||||
@Published var error: String?
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
|
||||
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
|
||||
let (p, lib) = try await (profileFetch, libraryFetch)
|
||||
profile = p
|
||||
currentlyReading = lib.currentlyReading
|
||||
library = lib.library
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(404, _): error = "User not found."
|
||||
default: error = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSubscribe() async {
|
||||
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
|
||||
isTogglingSubscribe = true
|
||||
defer { isTogglingSubscribe = false }
|
||||
do {
|
||||
if p.isSubscribed {
|
||||
try await APIClient.shared.unsubscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: max(0, p.followerCount - 1),
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: false, isSelf: p.isSelf
|
||||
)
|
||||
} else {
|
||||
try await APIClient.shared.subscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: p.followerCount + 1,
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: true, isSelf: p.isSelf
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
|
||||
|
||||
private extension PublicUserProfile {
|
||||
init(id: String, username: String, avatarUrl: String?, created: String,
|
||||
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
|
||||
// Encode then decode to go through the standard Decodable path without duplicating code
|
||||
var dict: [String: Any] = [
|
||||
"id": id, "username": username, "created": created,
|
||||
"followerCount": followerCount, "followingCount": followingCount,
|
||||
"isSubscribed": isSubscribed, "isSelf": isSelf
|
||||
]
|
||||
if let url = avatarUrl { dict["avatarUrl"] = url }
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
|
||||
}
|
||||
}
|
||||
127
ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift
Normal file
127
ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
@MainActor
|
||||
class VoiceSelectionViewModel: ObservableObject {
|
||||
@Published var voices: [String] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
@Published var playingVoice: String?
|
||||
|
||||
private var audioPlayer: AVPlayer?
|
||||
// Store the opaque token returned by the block-based addObserver so we can
|
||||
// actually remove it later. removeObserver(self, ...) does nothing when the
|
||||
// block-based API was used — the token is the observer, not `self`.
|
||||
private var endObserverToken: NSObjectProtocol?
|
||||
|
||||
// Voice label formatting (matches web UI logic)
|
||||
func voiceLabel(_ voice: String) -> String {
|
||||
let parts = voice.split(separator: "_")
|
||||
guard parts.count >= 2 else { return voice }
|
||||
|
||||
let prefix = String(parts[0])
|
||||
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||
|
||||
var info = ""
|
||||
switch prefix {
|
||||
case "af": info = "US F"
|
||||
case "am": info = "US M"
|
||||
case "bf": info = "UK F"
|
||||
case "bm": info = "UK M"
|
||||
default: info = prefix.uppercased()
|
||||
}
|
||||
|
||||
return "\(name) (\(info))"
|
||||
}
|
||||
|
||||
func voiceId(_ voice: String) -> String { voice }
|
||||
|
||||
// Load available voices from API
|
||||
func loadVoices() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let fetchedVoices = try await APIClient.shared.voices()
|
||||
voices = fetchedVoices.isEmpty ? fallbackVoices() : fetchedVoices
|
||||
} catch {
|
||||
self.error = "Failed to load voices: \(error.localizedDescription)"
|
||||
voices = fallbackVoices()
|
||||
}
|
||||
}
|
||||
|
||||
// Play voice sample
|
||||
func playSample(_ voice: String) async {
|
||||
if playingVoice == voice {
|
||||
stopSample()
|
||||
return
|
||||
}
|
||||
|
||||
stopSample()
|
||||
playingVoice = voice
|
||||
|
||||
do {
|
||||
let presignedURL = try await APIClient.shared.presignVoiceSample(voice: voice)
|
||||
guard let url = URL(string: presignedURL) else {
|
||||
throw NSError(domain: "VoiceSelection", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
|
||||
}
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
audioPlayer = AVPlayer(playerItem: playerItem)
|
||||
|
||||
// Block-based addObserver returns a token — store it so we can remove it.
|
||||
endObserverToken = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.stopSample()
|
||||
}
|
||||
}
|
||||
|
||||
audioPlayer?.play()
|
||||
} catch {
|
||||
// Sample might not be generated yet — silently ignore.
|
||||
print("Voice sample not available for \(voice): \(error)")
|
||||
playingVoice = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stop currently playing sample
|
||||
func stopSample() {
|
||||
audioPlayer?.pause()
|
||||
audioPlayer = nil
|
||||
playingVoice = nil
|
||||
if let token = endObserverToken {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
endObserverToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackVoices() -> [String] {
|
||||
["af_bella", "af_sarah", "af_nicole",
|
||||
"am_adam", "am_michael",
|
||||
"bf_emma", "bf_isabella",
|
||||
"bm_george", "bm_lewis",
|
||||
"af_sky"]
|
||||
}
|
||||
|
||||
// deinit: must NOT dispatch a Task capturing self.
|
||||
// A Task strongly retains self, which causes "deallocated with non-zero retain
|
||||
// count 2" → SIGABRT. Instead capture just the two values we need (player and
|
||||
// token) and clean up without touching self at all.
|
||||
nonisolated deinit {
|
||||
// Capture locals — self is going away, do not reference it after this point.
|
||||
// audioPlayer and endObserverToken are actor-isolated, but we can read their
|
||||
// stored value directly in deinit because deinit is the last exclusive owner.
|
||||
// Suppress the "actor-isolated" warning with an unowned reference pattern:
|
||||
// Swift SE-0371 allows nonisolated deinit to access stored properties directly.
|
||||
audioPlayer?.pause()
|
||||
if let token = endObserverToken {
|
||||
NotificationCenter.default.removeObserver(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ios/LibNovel/LibNovel/Views/Auth/AuthView.swift
Normal file
123
ios/LibNovel/LibNovel/Views/Auth/AuthView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AuthView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@State private var mode: Mode = .login
|
||||
@State private var username: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var confirmPassword: String = ""
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Mode { case login, register }
|
||||
enum Field { case username, password, confirmPassword }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Logo / header
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.amber)
|
||||
Text("LibNovel")
|
||||
.font(.largeTitle.bold())
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.bottom, 40)
|
||||
|
||||
// Tab switcher
|
||||
Picker("Mode", selection: $mode) {
|
||||
Text("Sign In").tag(Mode.login)
|
||||
Text("Create Account").tag(Mode.register)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
|
||||
// Form
|
||||
VStack(spacing: 16) {
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($focusedField, equals: .username)
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focusedField = .password }
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focusedField, equals: .password)
|
||||
.submitLabel(mode == .register ? .next : .go)
|
||||
.onSubmit {
|
||||
if mode == .register { focusedField = .confirmPassword }
|
||||
else { submit() }
|
||||
}
|
||||
|
||||
if mode == .register {
|
||||
SecureField("Confirm Password", text: $confirmPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($focusedField, equals: .confirmPassword)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { submit() }
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.easeInOut(duration: 0.2), value: mode)
|
||||
|
||||
if let error = authStore.error {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Button(action: submit) {
|
||||
Group {
|
||||
if authStore.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text(mode == .login ? "Sign In" : "Create Account")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
.disabled(authStore.isLoading || !formIsValid)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in
|
||||
authStore.error = nil
|
||||
confirmPassword = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var formIsValid: Bool {
|
||||
let base = !username.isEmpty && password.count >= 4
|
||||
if mode == .register { return base && password == confirmPassword }
|
||||
return base
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
focusedField = nil
|
||||
Task {
|
||||
if mode == .login {
|
||||
await authStore.login(username: username, password: password)
|
||||
} else {
|
||||
await authStore.register(username: username, password: password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
708
ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift
Normal file
708
ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift
Normal file
@@ -0,0 +1,708 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct BookDetailView: View {
|
||||
let slug: String
|
||||
@StateObject private var vm: BookDetailViewModel
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@State private var summaryExpanded = false
|
||||
@State private var showChapters = false
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
_vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
||||
} else if let book = vm.book {
|
||||
heroSection(book: book)
|
||||
metaSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
chaptersRow(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
CommentsView(slug: slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appNavigationDestination()
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showChapters) {
|
||||
BookChaptersSheet(
|
||||
slug: slug,
|
||||
chapters: vm.chapters,
|
||||
lastChapter: vm.lastChapter,
|
||||
totalChapters: vm.book?.totalChapters ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
|
||||
@ViewBuilder
|
||||
private func heroSection(book: Book) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Full-bleed blurred background
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 320)
|
||||
.blur(radius: 24)
|
||||
.clipped()
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.15), .black.opacity(0.68)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray5))
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(book.title)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Text(book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
|
||||
if !book.genres.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||
TagChip(label: genre).colorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !book.status.isEmpty {
|
||||
StatusBadge(status: book.status)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.frame(minHeight: 320)
|
||||
}
|
||||
|
||||
// MARK: - Meta section (stats + summary + CTAs)
|
||||
|
||||
@ViewBuilder
|
||||
private func metaSection(book: Book) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Quick stats row
|
||||
HStack(spacing: 0) {
|
||||
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(
|
||||
value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status", icon: "flag"
|
||||
)
|
||||
if book.ranking > 0 {
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Summary
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
|
||||
|
||||
if book.summary.count > 200 {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// CTA buttons
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Start Reading", systemImage: "book.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact chapters row (tap → sheet)
|
||||
|
||||
@ViewBuilder
|
||||
private func chaptersRow(book: Book) -> some View {
|
||||
Button {
|
||||
showChapters = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "list.number")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapters")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !vm.chapters.isEmpty {
|
||||
let last = vm.lastChapter
|
||||
let total = vm.chapters.count
|
||||
Text(last != nil && last! > 0
|
||||
? "Reading Ch.\(last!) of \(total)"
|
||||
: "\(total) chapter\(total == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if vm.isLoading {
|
||||
Text("Loading…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Bookmark toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var bookmarkButton: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await vm.toggleSaved() }
|
||||
} label: {
|
||||
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
|
||||
.foregroundStyle(vm.saved ? .amber : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chapters list sheet
|
||||
// Apple Books-style: chapters grouped into blocks of 100 with a right-edge jump bar.
|
||||
// A .searchable bar filters by number or title; an "offline only" toggle shows downloaded chapters.
|
||||
// Per-row download status (arc ring, labels, swipe actions) mirrors ChaptersListSheet in PlayerViews.
|
||||
|
||||
struct BookChaptersSheet: View {
|
||||
let slug: String
|
||||
let chapters: [ChapterIndex]
|
||||
let lastChapter: Int?
|
||||
let totalChapters: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var downloadService: AudioDownloadService
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
@State private var searchText: String = ""
|
||||
@State private var filterOfflineOnly = false
|
||||
@State private var showingDownloadAll = false
|
||||
/// The block label the jump bar is currently scrolling to (e.g. "1–100").
|
||||
@State private var activeBlock: String? = nil
|
||||
|
||||
// MARK: Derived data
|
||||
|
||||
private var downloadedCount: Int {
|
||||
chapters.filter { ch in
|
||||
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||
}.count
|
||||
}
|
||||
|
||||
private var downloadingCount: Int {
|
||||
downloadService.downloads.filter { key, _ in
|
||||
key.hasPrefix("\(slug)::")
|
||||
}.count
|
||||
}
|
||||
|
||||
private var defaultVoice: String {
|
||||
BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: audioPlayer.voice)
|
||||
}
|
||||
|
||||
private var filtered: [ChapterIndex] {
|
||||
var result = chapters
|
||||
|
||||
if filterOfflineOnly {
|
||||
result = result.filter { ch in
|
||||
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||
}
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
let q = searchText.lowercased()
|
||||
result = result.filter {
|
||||
"\($0.number)".contains(q) ||
|
||||
$0.title.lowercased().contains(q) ||
|
||||
"chapter \($0.number)".contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Chapters grouped into blocks of 100 with range labels "1–100", "101–200", etc.
|
||||
/// When searching or filtering the jump bar is hidden and a flat "Results" group is used.
|
||||
private var groups: [(label: String, chapters: [ChapterIndex])] {
|
||||
guard searchText.isEmpty && !filterOfflineOnly else {
|
||||
return filtered.isEmpty ? [] : [("Results", filtered)]
|
||||
}
|
||||
guard !filtered.isEmpty else { return [] }
|
||||
let blockSize = 100
|
||||
let minN = filtered.map(\.number).min() ?? 1
|
||||
let maxN = filtered.map(\.number).max() ?? 1
|
||||
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
|
||||
var result: [(label: String, chapters: [ChapterIndex])] = []
|
||||
var blockStart = firstBlock
|
||||
while blockStart <= maxN {
|
||||
let blockEnd = blockStart + blockSize - 1
|
||||
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
|
||||
if !slice.isEmpty {
|
||||
result.append(("\(blockStart)–\(blockEnd)", slice))
|
||||
}
|
||||
blockStart += blockSize
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private var jumpLabels: [String] { groups.map(\.label) }
|
||||
|
||||
// MARK: Body
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .trailing) {
|
||||
// ── Main chapter list ──────────────────────────────────────
|
||||
List {
|
||||
// Offline downloads summary (shown when at least one chapter is downloaded)
|
||||
if downloadedCount > 0 || downloadingCount > 0 {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Offline Downloads")
|
||||
.font(.headline)
|
||||
Text("\(downloadedCount) of \(chapters.count) chapters")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingDownloadAll = true
|
||||
} label: {
|
||||
Label("Manage", systemImage: "arrow.down.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
if downloadingCount > 0 {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Show offline only", isOn: $filterOfflineOnly)
|
||||
.font(.subheadline)
|
||||
.tint(.amber)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(groups, id: \.label) { group in
|
||||
Section {
|
||||
ForEach(group.chapters, id: \.number) { ch in
|
||||
BookChapterRow(
|
||||
chapter: ch,
|
||||
slug: slug,
|
||||
isCurrent: ch.number == lastChapter,
|
||||
voice: defaultVoice
|
||||
)
|
||||
.id(group.label)
|
||||
}
|
||||
} header: {
|
||||
if searchText.isEmpty && !filterOfflineOnly {
|
||||
Text(group.label)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.id("header_\(group.label)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chapters.isEmpty {
|
||||
Section {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Chapter number or title"
|
||||
)
|
||||
.scrollPosition(id: $activeBlock, anchor: .top)
|
||||
.appNavigationDestination()
|
||||
|
||||
// ── Right-edge jump bar ────────────────────────────────────
|
||||
if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 {
|
||||
BookChaptersJumpBar(
|
||||
labels: jumpLabels,
|
||||
currentChapter: lastChapter ?? 0,
|
||||
groups: groups
|
||||
) { label in
|
||||
withAnimation { activeBlock = label }
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapters (\(filtered.count))")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
// Sheet to manage bulk downloads for this book
|
||||
.sheet(isPresented: $showingDownloadAll) {
|
||||
DownloadManagementSheet(
|
||||
chapters: chapters.map { ChapterIndexBrief(number: $0.number, title: $0.title) },
|
||||
slug: slug,
|
||||
voice: Binding(
|
||||
get: { defaultVoice },
|
||||
set: { _ in } // voice changes handled inside DownloadManagementSheet
|
||||
)
|
||||
)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
// Scroll to the current chapter's block on first appear
|
||||
.onAppear {
|
||||
if let block = groups.first(where: { g in
|
||||
g.chapters.contains(where: { $0.number == (lastChapter ?? 0) })
|
||||
}) {
|
||||
activeBlock = block.label
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Individual chapter row with download status + NavigationLink
|
||||
|
||||
private struct BookChapterRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let slug: String
|
||||
let isCurrent: Bool
|
||||
let voice: String
|
||||
|
||||
@EnvironmentObject var downloadService: AudioDownloadService
|
||||
|
||||
private var isDownloaded: Bool {
|
||||
downloadService.isDownloaded(slug: slug, chapter: chapter.number, voice: voice)
|
||||
}
|
||||
|
||||
private var downloadProgress: DownloadProgress? {
|
||||
let key = downloadService.makeKey(slug: slug, chapter: chapter.number, voice: voice)
|
||||
return downloadService.downloads[key]
|
||||
}
|
||||
|
||||
private var isDownloading: Bool { downloadProgress != nil }
|
||||
|
||||
private var displayTitle: String {
|
||||
let stripped = chapter.title.strippingTrailingDate()
|
||||
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||
return "Chapter \(chapter.number)"
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
|
||||
HStack(spacing: 14) {
|
||||
// Number badge with optional download-progress arc ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isCurrent ? Color.amber : Color(.systemGray5))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Text("\(chapter.number)")
|
||||
.font(.caption.bold().monospacedDigit())
|
||||
.foregroundStyle(isCurrent ? .white : .secondary)
|
||||
.minimumScaleFactor(0.6)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
// In-progress download arc
|
||||
if isDownloading, let progress = downloadProgress {
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.progress)
|
||||
.stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 44, height: 44)
|
||||
.animation(.easeInOut(duration: 0.3), value: progress.progress)
|
||||
}
|
||||
}
|
||||
|
||||
// Title + status subtitle
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(displayTitle)
|
||||
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
|
||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if isCurrent {
|
||||
Label("Reading", systemImage: "bookmark.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
|
||||
if isDownloading, let progress = downloadProgress {
|
||||
Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
} else if isDownloaded {
|
||||
Label("Downloaded", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
} else if !chapter.dateLabel.isEmpty {
|
||||
Text(chapter.dateLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||||
// Trailing swipe: Download / Cancel / Delete
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
if isDownloaded {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
try? downloadService.deleteDownload(
|
||||
slug: slug, chapter: chapter.number, voice: voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} else if isDownloading {
|
||||
Button(role: .destructive) {
|
||||
downloadService.cancelDownload(
|
||||
slug: slug, chapter: chapter.number, voice: voice
|
||||
)
|
||||
} label: {
|
||||
Label("Cancel", systemImage: "xmark")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
try? await downloadService.download(
|
||||
slug: slug, chapter: chapter.number, voice: voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Label("Download", systemImage: "arrow.down.circle")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Right-edge jump bar for BookChaptersSheet
|
||||
// Mirrors the JumpBar in PlayerViews.swift but operates on ChapterIndex groups.
|
||||
|
||||
private struct BookChaptersJumpBar: View {
|
||||
let labels: [String]
|
||||
let currentChapter: Int
|
||||
let groups: [(label: String, chapters: [ChapterIndex])]
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
@State private var isDragging = false
|
||||
|
||||
private func shortLabel(_ full: String) -> String {
|
||||
full.components(separatedBy: "–").first ?? full
|
||||
}
|
||||
|
||||
private var currentBlock: String? {
|
||||
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(labels, id: \.self) { label in
|
||||
let isCurrent = label == currentBlock
|
||||
Text(shortLabel(label))
|
||||
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
|
||||
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(label) }
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.shadow(color: .black.opacity(0.15), radius: 4)
|
||||
)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
let itemHeight: CGFloat = 28
|
||||
let index = Int(value.location.y / itemHeight)
|
||||
let clamped = max(0, min(labels.count - 1, index))
|
||||
onSelect(labels[clamped])
|
||||
}
|
||||
.onEnded { _ in isDragging = false }
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting components
|
||||
|
||||
private struct MetaStat: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusBadge: View {
|
||||
let status: String
|
||||
|
||||
private var color: Color {
|
||||
switch status.lowercased() {
|
||||
case "ongoing", "active": return .green
|
||||
case "completed": return .blue
|
||||
case "hiatus": return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(status.capitalized)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(color.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
@@ -0,0 +1,643 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
class CommentsViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var comments: [BookComment] = []
|
||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||
@Published var avatarUrls: [String: String] = [:] // userId → presigned URL
|
||||
@Published var isLoading = true
|
||||
@Published var error: String?
|
||||
|
||||
@Published var newBody = ""
|
||||
@Published var isPosting = false
|
||||
@Published var postError: String?
|
||||
|
||||
@Published var sort: CommentSortOrder = .top
|
||||
|
||||
// Reply state
|
||||
@Published var replyingToId: String? = nil
|
||||
@Published var replyBody = ""
|
||||
@Published var isPostingReply = false
|
||||
@Published var replyError: String?
|
||||
|
||||
private var votingIds: Set<String> = []
|
||||
private var deletingIds: Set<String> = []
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue)
|
||||
comments = response.comments
|
||||
myVotes = response.myVotes
|
||||
avatarUrls = response.avatarUrls
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func postComment() async {
|
||||
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPosting else { return }
|
||||
if text.count > 2000 {
|
||||
postError = "Comment too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPosting = true
|
||||
postError = nil
|
||||
do {
|
||||
var created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
created.replies = []
|
||||
comments.insert(created, at: 0)
|
||||
newBody = ""
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): postError = "You must be logged in to comment."
|
||||
default: postError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
postError = error.localizedDescription
|
||||
}
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
func postReply(parentId: String) async {
|
||||
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPostingReply else { return }
|
||||
if text.count > 2000 {
|
||||
replyError = "Reply too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPostingReply = true
|
||||
replyError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(created)
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
}
|
||||
replyBody = ""
|
||||
replyingToId = nil
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): replyError = "You must be logged in to reply."
|
||||
default: replyError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
replyError = error.localizedDescription
|
||||
}
|
||||
isPostingReply = false
|
||||
}
|
||||
|
||||
func deleteComment(commentId: String, parentId: String? = nil) async {
|
||||
guard !deletingIds.contains(commentId) else { return }
|
||||
deletingIds.insert(commentId)
|
||||
|
||||
// Optimistic removal — update the UI immediately before the network call
|
||||
var removedComment: BookComment?
|
||||
var removedAtIndex: Int?
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
removedComment = parent.replies?.first(where: { $0.id == commentId })
|
||||
removedAtIndex = idx
|
||||
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
removedAtIndex = comments.firstIndex(where: { $0.id == commentId })
|
||||
removedComment = removedAtIndex.map { comments[$0] }
|
||||
comments.removeAll { $0.id == commentId }
|
||||
}
|
||||
|
||||
do {
|
||||
try await APIClient.shared.deleteComment(commentId: commentId)
|
||||
} catch {
|
||||
// Revert the optimistic removal on failure
|
||||
if let removed = removedComment {
|
||||
if let parentId, let idx = removedAtIndex {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(removed)
|
||||
replies.sort { $0.created < $1.created }
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
} else if let idx = removedAtIndex {
|
||||
comments.insert(removed, at: min(idx, comments.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deletingIds.remove(commentId)
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String, parentId: String? = nil) async {
|
||||
guard !votingIds.contains(commentId) else { return }
|
||||
votingIds.insert(commentId)
|
||||
defer { votingIds.remove(commentId) }
|
||||
do {
|
||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
|
||||
parent.replies![rIdx] = updated
|
||||
}
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
var c = updated
|
||||
c.replies = comments[idx].replies
|
||||
comments[idx] = c
|
||||
}
|
||||
}
|
||||
let prev = myVotes[commentId]
|
||||
if prev == vote {
|
||||
myVotes.removeValue(forKey: commentId)
|
||||
} else {
|
||||
myVotes[commentId] = vote
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore vote errors
|
||||
}
|
||||
}
|
||||
|
||||
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
|
||||
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
|
||||
|
||||
func setSort(_ newSort: CommentSortOrder) {
|
||||
guard newSort != sort else { return }
|
||||
sort = newSort
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
enum CommentSortOrder: String, CaseIterable {
|
||||
case top = "top"
|
||||
case new = "new"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .top: return "Top"
|
||||
case .new: return "New"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentsView
|
||||
|
||||
struct CommentsView: View {
|
||||
@StateObject private var vm: CommentsViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(slug: String) {
|
||||
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header + sort picker
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
|
||||
if !vm.isLoading && total > 0 {
|
||||
Text("(\(total))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// Sort picker
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Picker("Sort", selection: Binding(
|
||||
get: { vm.sort },
|
||||
set: { vm.setSort($0) }
|
||||
)) {
|
||||
ForEach(CommentSortOrder.allCases, id: \.self) { s in
|
||||
Text(s.label).tag(s)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Post form
|
||||
postForm
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Comment list
|
||||
if vm.isLoading {
|
||||
loadingPlaceholder
|
||||
} else if let err = vm.error {
|
||||
Text(err)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
} else if vm.comments.isEmpty {
|
||||
Text("No comments yet. Be the first!")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(vm.comments) { comment in
|
||||
commentThread(comment: comment)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 16)
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Comment thread (top-level + replies)
|
||||
|
||||
@ViewBuilder
|
||||
private func commentThread(comment: BookComment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id),
|
||||
isDeleting: vm.isDeleting(comment.id),
|
||||
isOwner: authStore.user?.id == comment.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: vm.replyingToId == comment.id,
|
||||
avatarUrl: vm.avatarUrls[comment.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
|
||||
onReply: {
|
||||
if vm.replyingToId == comment.id {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
} else {
|
||||
vm.replyingToId = comment.id
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Inline reply form
|
||||
if vm.replyingToId == comment.id {
|
||||
replyForm(parentId: comment.id)
|
||||
.padding(.leading, 32)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Replies
|
||||
if let replies = comment.replies, !replies.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(replies) { reply in
|
||||
CommentRow(
|
||||
comment: reply,
|
||||
myVote: vm.myVotes[reply.id],
|
||||
isVoting: vm.isVoting(reply.id),
|
||||
isDeleting: vm.isDeleting(reply.id),
|
||||
isOwner: authStore.user?.id == reply.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: false,
|
||||
isReply: true,
|
||||
avatarUrl: vm.avatarUrls[reply.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
|
||||
onReply: nil
|
||||
)
|
||||
if reply.id != replies.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: 2)
|
||||
.padding(.leading, 16)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reply form
|
||||
|
||||
@ViewBuilder
|
||||
private func replyForm(parentId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.replyBody.isEmpty {
|
||||
Text("Write a reply…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.replyBody)
|
||||
.font(.caption)
|
||||
.frame(minHeight: 56, maxHeight: 120)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
HStack {
|
||||
let count = vm.replyBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.replyError {
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
|
||||
}
|
||||
|
||||
Button("Cancel") {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
Task { await vm.postReply(parentId: parentId) }
|
||||
} label: {
|
||||
if vm.isPostingReply {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text("Reply").fontWeight(.semibold).font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.mini)
|
||||
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
if authStore.isAuthenticated {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Log in to leave a comment.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading skeleton
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingPlaceholder: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 100, height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 200, height: 12)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentRow
|
||||
|
||||
private struct CommentRow: View {
|
||||
let comment: BookComment
|
||||
let myVote: String?
|
||||
let isVoting: Bool
|
||||
let isDeleting: Bool
|
||||
let isOwner: Bool
|
||||
let isLoggedIn: Bool
|
||||
let isReplyingTo: Bool
|
||||
var isReply: Bool = false
|
||||
var avatarUrl: String? = nil
|
||||
let onVote: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReply: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Avatar + Username + date
|
||||
HStack(spacing: 8) {
|
||||
avatarView
|
||||
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(comment.username.isEmpty)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formattedDate(comment.created))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Body
|
||||
Text(comment.body)
|
||||
.font(isReply ? .caption : .subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Actions
|
||||
HStack(spacing: 14) {
|
||||
// Upvote
|
||||
Button { onVote("up") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||
.font(.caption)
|
||||
Text("\(comment.upvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Downvote
|
||||
Button { onVote("down") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||
.font(.caption)
|
||||
Text("\(comment.downvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "down" ? .red : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Reply button (top-level only, logged in)
|
||||
if let onReply, isLoggedIn {
|
||||
Button { onReply() } label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
.font(.caption)
|
||||
Text("Reply")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete (owner only)
|
||||
if isOwner {
|
||||
Button(role: .destructive) { onDelete() } label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.opacity(isDeleting ? 0.5 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDeleting)
|
||||
}
|
||||
|
||||
private var avatarSize: CGFloat { isReply ? 20 : 24 }
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
if let url = avatarUrl, let imageUrl = URL(string: url) {
|
||||
AsyncImage(url: imageUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
|
||||
private var initialsView: some View {
|
||||
let name = comment.username.isEmpty ? "?" : comment.username
|
||||
let letters = String(name.prefix(2)).uppercased()
|
||||
return ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
Text(letters)
|
||||
.font(.system(size: avatarSize * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ iso: String) -> String {
|
||||
// PocketBase returns "2006-01-02 15:04:05.999Z" format
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
// Fallback: try space-separated format
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = df.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
567
ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift
Normal file
567
ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift
Normal file
@@ -0,0 +1,567 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Discover View (Browse)
|
||||
// Serendipity-focused browsing with curated shelves.
|
||||
// No search bar — use the dedicated Search tab for that.
|
||||
|
||||
struct BrowseView: View {
|
||||
@StateObject private var vm = DiscoverViewModel()
|
||||
@State private var showGenreSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.trending.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMsg = vm.error, vm.trending.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(errorMsg)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") { Task { await vm.load() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 32) {
|
||||
// Trending shelf
|
||||
if !vm.trending.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Trending Now",
|
||||
novels: vm.trending,
|
||||
destination: .browseCategory(
|
||||
sort: "popular",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Trending Now"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Top Rated shelf
|
||||
if !vm.topRated.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Top Rated",
|
||||
novels: vm.topRated,
|
||||
destination: .browseCategory(
|
||||
sort: "rating",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Top Rated"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Recently Updated shelf
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Recently Updated",
|
||||
novels: vm.recentlyUpdated,
|
||||
destination: .browseCategory(
|
||||
sort: "updated",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Recently Updated"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// New Releases shelf
|
||||
if !vm.newReleases.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "New Releases",
|
||||
novels: vm.newReleases,
|
||||
destination: .browseCategory(
|
||||
sort: "new",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "New Releases"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Categories button — replaces individual genre shelves
|
||||
CategoriesRow(onTap: { showGenreSheet = true })
|
||||
.padding(.horizontal)
|
||||
|
||||
Color.clear.frame(height: 100)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.refreshable { await vm.load() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showGenreSheet) {
|
||||
GenrePickerSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Categories row (Apple Books–style single button)
|
||||
|
||||
private struct CategoriesRow: View {
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.amber.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: "square.grid.2x2")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Browse by Genre")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Action, Fantasy, Romance & more")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Genre picker sheet
|
||||
|
||||
private struct GenrePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let genres: [(label: String, genre: String, icon: String)] = [
|
||||
("Action", "action", "bolt.fill"),
|
||||
("Fantasy", "fantasy", "wand.and.stars"),
|
||||
("Romance", "romance", "heart.fill"),
|
||||
("Sci-Fi", "sci-fi", "sparkles"),
|
||||
("Mystery", "mystery", "magnifyingglass"),
|
||||
("Horror", "horror", "moon.fill"),
|
||||
("Comedy", "comedy", "face.smiling"),
|
||||
("Adventure", "adventure", "map.fill"),
|
||||
("Martial Arts", "martial arts", "figure.martial.arts"),
|
||||
("Cultivation", "cultivation", "leaf.fill"),
|
||||
("Historical", "historical", "building.columns.fill"),
|
||||
("Slice of Life", "slice of life", "sun.max.fill"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12),
|
||||
GridItem(.flexible(), spacing: 12)
|
||||
],
|
||||
spacing: 12
|
||||
) {
|
||||
// "All" tile
|
||||
NavigationLink(value: NavDestination.browseCategory(
|
||||
sort: "popular", genre: "all", status: "all", title: "All Novels"
|
||||
)) {
|
||||
GenreTile(label: "All Novels", icon: "books.vertical.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||
|
||||
ForEach(genres, id: \.genre) { item in
|
||||
NavigationLink(value: NavDestination.browseCategory(
|
||||
sort: "popular",
|
||||
genre: item.genre,
|
||||
status: "all",
|
||||
title: item.label
|
||||
)) {
|
||||
GenreTile(label: item.label, icon: item.icon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.navigationTitle("Genres")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationCornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenreTile: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 24)
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Discover Shelf (horizontal scrolling)
|
||||
|
||||
private struct DiscoverShelf: View {
|
||||
let title: String
|
||||
let novels: [BrowseNovel]
|
||||
let destination: NavDestination
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Header with "See All" button
|
||||
HStack(spacing: 10) {
|
||||
// Amber accent bar — matches ShelfHeader style used on Home and UserProfile
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.amber)
|
||||
.frame(width: 3, height: 18)
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
NavigationLink(value: destination) {
|
||||
HStack(spacing: 4) {
|
||||
Text("See All")
|
||||
.font(.subheadline)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.bold())
|
||||
}
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Horizontal scroll — leading padding aligns cards with header
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
DiscoverShelfCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 4) // let shadows breathe
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf card (card-style)
|
||||
|
||||
private struct DiscoverShelfCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(width: 120, height: 173) // 2:3 ratio
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(width: 136)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse Category View (full grid for "See All")
|
||||
|
||||
struct BrowseCategoryView: View {
|
||||
let sort: String
|
||||
let genre: String
|
||||
let status: String
|
||||
let title: String
|
||||
|
||||
@StateObject private var vm: BrowseViewModel
|
||||
@State private var showFilters = false
|
||||
|
||||
init(sort: String, genre: String, status: String, title: String) {
|
||||
self.sort = sort
|
||||
self.genre = genre
|
||||
self.status = status
|
||||
self.title = title
|
||||
|
||||
let viewModel = BrowseViewModel()
|
||||
viewModel.sort = sort
|
||||
viewModel.genre = genre
|
||||
viewModel.status = status
|
||||
_vm = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if vm.isLoading && vm.novels.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMsg = vm.error, vm.novels.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(errorMsg)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") { Task { await vm.loadFirstPage() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)
|
||||
],
|
||||
spacing: 14
|
||||
) {
|
||||
ForEach(vm.novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
BrowseCategoryCard(novel: novel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
// Infinite scroll
|
||||
if novel.id == vm.novels.last?.id {
|
||||
Task { await vm.loadNextPage() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 100)
|
||||
|
||||
if vm.isLoading && !vm.novels.isEmpty {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.refreshable { await vm.loadFirstPage() }
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showFilters = true
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersView(vm: vm)
|
||||
}
|
||||
.task {
|
||||
if vm.novels.isEmpty {
|
||||
await vm.loadFirstPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowseCategoryCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !novel.author.isEmpty {
|
||||
Text(novel.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filters sheet (kept for future "See All" views)
|
||||
|
||||
struct BrowseFiltersView: View {
|
||||
@ObservedObject var vm: BrowseViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let sortOptions = ["popular", "new", "updated", "rating", "rank"]
|
||||
let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery",
|
||||
"horror", "comedy", "drama", "adventure", "martial arts",
|
||||
"cultivation", "magic", "supernatural", "historical", "slice of life"]
|
||||
let statusOptions = ["all", "ongoing", "completed"]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Sort") {
|
||||
ForEach(sortOptions, id: \.self) { opt in
|
||||
HStack {
|
||||
Text(opt.capitalized)
|
||||
Spacer()
|
||||
if vm.sort == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vm.sort = opt; dismiss() }
|
||||
}
|
||||
}
|
||||
Section("Genre") {
|
||||
ForEach(genreOptions, id: \.self) { opt in
|
||||
HStack {
|
||||
Text(opt == "all" ? "All Genres" : opt.capitalized)
|
||||
Spacer()
|
||||
if vm.genre == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vm.genre = opt; dismiss() }
|
||||
}
|
||||
}
|
||||
Section("Status") {
|
||||
ForEach(statusOptions, id: \.self) { opt in
|
||||
HStack {
|
||||
Text(opt.capitalized)
|
||||
Spacer()
|
||||
if vm.status == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vm.status = opt; dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
1240
ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift
Normal file
1240
ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Download Audio Button
|
||||
// Shows download status and allows users to download/delete offline audio.
|
||||
// Uses symbolEffect + spring animations for a modern, tactile feel.
|
||||
|
||||
struct DownloadAudioButton: View {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
let voice: String
|
||||
let theme: ReaderTheme
|
||||
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@State private var showDownloadMenu = false
|
||||
@State private var bounceDownload = false
|
||||
|
||||
private var downloadKey: String {
|
||||
AudioDownloadService.shared.makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
private var isDownloaded: Bool {
|
||||
downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
|
||||
private var downloadProgress: DownloadProgress? {
|
||||
downloadService.downloads[downloadKey]
|
||||
}
|
||||
|
||||
private var accentColor: Color {
|
||||
theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showDownloadMenu = true
|
||||
} label: {
|
||||
ZStack {
|
||||
// Background pill
|
||||
Circle()
|
||||
.fill(backgroundFillColor)
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
stateIcon
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDownloaded)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: downloadProgress?.status.isDownloading)
|
||||
.confirmationDialog("Audio Download", isPresented: $showDownloadMenu) {
|
||||
if isDownloaded {
|
||||
Button("Delete Download", role: .destructive) {
|
||||
Task {
|
||||
try? await downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
}
|
||||
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||
Button("Cancel Download", role: .destructive) {
|
||||
downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
} else {
|
||||
Button("Download for Offline") {
|
||||
Task {
|
||||
try? await downloadService.download(slug: slug, chapter: chapter, voice: voice)
|
||||
}
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { bounceDownload.toggle() }
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
if isDownloaded {
|
||||
Text("This chapter's audio is downloaded for offline listening.")
|
||||
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||
Text("Downloading… \(Int(progress.progress * 100))%")
|
||||
} else {
|
||||
Text("Download this chapter's audio to listen offline without internet connection.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
private var backgroundFillColor: Color {
|
||||
if isDownloaded {
|
||||
return Color.green.opacity(0.15)
|
||||
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||
return accentColor.opacity(0.1)
|
||||
} else if let progress = downloadProgress, case .failed = progress.status {
|
||||
return Color.red.opacity(0.12)
|
||||
} else {
|
||||
return theme.textColor.opacity(0.07)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Icon
|
||||
|
||||
@ViewBuilder
|
||||
private var stateIcon: some View {
|
||||
if isDownloaded {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.green)
|
||||
.symbolEffect(.bounce, value: isDownloaded)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
|
||||
} else if let progress = downloadProgress {
|
||||
switch progress.status {
|
||||
case .downloading:
|
||||
ZStack {
|
||||
// Track ring
|
||||
Circle()
|
||||
.stroke(accentColor.opacity(0.18), lineWidth: 2.5)
|
||||
// Progress arc
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.progress)
|
||||
.stroke(
|
||||
accentColor,
|
||||
style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.2), value: progress.progress)
|
||||
// Down arrow
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.red)
|
||||
.symbolEffect(.pulse)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
|
||||
case .completed:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
} else {
|
||||
// Idle — not yet downloaded
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(theme.textColor.opacity(0.55))
|
||||
.symbolEffect(.bounce, value: bounceDownload)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadStatus {
|
||||
var isDownloading: Bool {
|
||||
if case .downloading = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
164
ios/LibNovel/LibNovel/Views/Common/CommonViews.swift
Normal file
164
ios/LibNovel/LibNovel/Views/Common/CommonViews.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - Empty state placeholder used across all screens
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cover image card reused across screens
|
||||
|
||||
struct BookCard: View {
|
||||
let book: Book
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
Text(book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
Text(book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Async cover image with disk/memory caching via Kingfisher
|
||||
|
||||
struct AsyncCoverImage: View {
|
||||
let url: String
|
||||
/// When true the placeholder is a plain colour fill — used for blurred hero backgrounds
|
||||
/// so the rounded-rect loading indicator doesn't bleed through.
|
||||
var isBackground: Bool = false
|
||||
|
||||
var body: some View {
|
||||
KFImage(URL(string: url))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
if isBackground {
|
||||
Color(.systemGray6)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
}
|
||||
}
|
||||
.scaledToFill()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tag chip
|
||||
|
||||
struct TagChip: View {
|
||||
let label: String
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(.systemGray5), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified chip button (filter/sort chips across all screens)
|
||||
//
|
||||
// .filled → amber background when selected (genre filter chips in Library)
|
||||
// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips)
|
||||
|
||||
enum ChipButtonStyle { case filled, outlined }
|
||||
|
||||
struct ChipButton: View {
|
||||
let label: String
|
||||
let isSelected: Bool
|
||||
var style: ChipButtonStyle = .filled
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(chipFont)
|
||||
.padding(.horizontal, chipHPad)
|
||||
.padding(.vertical, 6)
|
||||
.background(background)
|
||||
.foregroundStyle(foregroundColor)
|
||||
.overlay(border)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var chipFont: Font {
|
||||
switch style {
|
||||
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
|
||||
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
switch style {
|
||||
case .filled:
|
||||
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
|
||||
case .outlined:
|
||||
Capsule()
|
||||
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
|
||||
.overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
|
||||
private var foregroundColor: Color {
|
||||
switch style {
|
||||
case .filled: return isSelected ? .white : .primary
|
||||
case .outlined: return isSelected ? .amber : .primary
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var border: some View {
|
||||
// outlined style already has its border baked into `background`
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf header (amber accent bar + title)
|
||||
// Used by HomeView, UserProfileView, BrowseView's DiscoverShelf, and any future shelf screen.
|
||||
// Call sites that need trailing content (e.g. a "See All" NavigationLink) wrap this in an HStack.
|
||||
|
||||
struct ShelfHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
// 3-pt amber accent bar — the brand visual anchor for all shelf titles
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.amber)
|
||||
.frame(width: 3, height: 18)
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
32
ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift
Normal file
32
ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Offline Banner
|
||||
// Subtle banner shown at top of screen when network is unavailable
|
||||
|
||||
struct OfflineBanner: View {
|
||||
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||
|
||||
var body: some View {
|
||||
if !networkMonitor.isConnected {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.caption)
|
||||
Text("You're offline")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text("Showing cached content")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.orange.opacity(0.3))
|
||||
.frame(height: 1)
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
340
ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift
Normal file
340
ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Download Queue Toolbar Button
|
||||
// Compact toolbar button that shows active download status and opens queue management sheet.
|
||||
// Shows:
|
||||
// - Download icon with badge count when downloads are active
|
||||
// - Progress ring around icon
|
||||
// - Taps opens DownloadQueueSheet for management
|
||||
|
||||
struct DownloadQueueButton: View {
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@State private var showQueue = false
|
||||
|
||||
private var activeDownloads: [DownloadProgress] {
|
||||
downloadService.downloads.values.filter { $0.status == .downloading }
|
||||
}
|
||||
|
||||
private var hasActiveDownloads: Bool {
|
||||
!activeDownloads.isEmpty
|
||||
}
|
||||
|
||||
private var averageProgress: Double {
|
||||
guard !activeDownloads.isEmpty else { return 0 }
|
||||
let total = activeDownloads.reduce(0.0) { $0 + $1.progress }
|
||||
return total / Double(activeDownloads.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showQueue = true
|
||||
} label: {
|
||||
ZStack {
|
||||
// Progress ring (only shown when downloading)
|
||||
if hasActiveDownloads {
|
||||
Circle()
|
||||
.stroke(Color.amber.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: averageProgress)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||
.frame(width: 30, height: 30)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.3), value: averageProgress)
|
||||
}
|
||||
|
||||
// Download icon
|
||||
Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(hasActiveDownloads ? .amber : .secondary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
||||
// Badge count (top-right corner)
|
||||
if activeDownloads.count > 0 {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(activeDownloads.count)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.frame(minWidth: 16)
|
||||
.background(Circle().fill(Color.red))
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6)
|
||||
.sheet(isPresented: $showQueue) {
|
||||
DownloadQueueSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Queue Management Sheet
|
||||
// Bottom sheet showing active downloads and quick management options
|
||||
|
||||
struct DownloadQueueSheet: View {
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var activeDownloads: [(key: String, value: DownloadProgress)] {
|
||||
downloadService.downloads
|
||||
.filter { $0.value.status == .downloading }
|
||||
.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private var failedDownloads: [(key: String, value: DownloadProgress)] {
|
||||
downloadService.downloads.compactMap { key, value in
|
||||
if case .failed = value.status {
|
||||
return (key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private var totalDownloaded: Int {
|
||||
downloadService.downloadedChapters.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 {
|
||||
emptyState
|
||||
} else {
|
||||
List {
|
||||
// Active downloads section
|
||||
if !activeDownloads.isEmpty {
|
||||
Section {
|
||||
ForEach(activeDownloads, id: \.key) { key, progress in
|
||||
ActiveDownloadRow(progress: progress)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Downloading")
|
||||
Spacer()
|
||||
Text("\(activeDownloads.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Failed downloads section
|
||||
if !failedDownloads.isEmpty {
|
||||
Section("Failed") {
|
||||
ForEach(failedDownloads, id: \.key) { key, progress in
|
||||
FailedDownloadRow(progress: progress, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick stats section
|
||||
Section {
|
||||
NavigationLink {
|
||||
DownloadsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Downloaded Chapters")
|
||||
Spacer()
|
||||
Text("\(totalDownloaded)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "internaldrive")
|
||||
.foregroundStyle(.amber)
|
||||
Text("Storage Used")
|
||||
Spacer()
|
||||
Text(storageUsedFormatted)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel all option (only show if there are active downloads)
|
||||
if !activeDownloads.isEmpty {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
activeDownloads.forEach { key, progress in
|
||||
downloadService.cancelDownload(
|
||||
slug: progress.slug,
|
||||
chapter: progress.chapter,
|
||||
voice: progress.voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Cancel All Downloads")
|
||||
.font(.subheadline.bold())
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Download Queue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
Text("No Active Downloads")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("Audio chapters you download will appear here")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var storageUsedFormatted: String {
|
||||
let bytes = downloadService.getTotalStorageUsed()
|
||||
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active Download Row
|
||||
|
||||
private struct ActiveDownloadRow: View {
|
||||
let progress: DownloadProgress
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Book/Chapter info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formatSlug(progress.slug))
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
Text("Chapter \(progress.chapter)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Progress indicator
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(progress.progress * 100))%")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
.monospacedDigit()
|
||||
|
||||
ProgressView(value: progress.progress)
|
||||
.frame(width: 60)
|
||||
.tint(.amber)
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
Button {
|
||||
downloadService.cancelDownload(
|
||||
slug: progress.slug,
|
||||
chapter: progress.chapter,
|
||||
voice: progress.voice
|
||||
)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatSlug(_ slug: String) -> String {
|
||||
// Convert slug to readable title (e.g., "my-book-title" -> "My Book Title")
|
||||
slug.split(separator: "-")
|
||||
.map { $0.capitalized }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Failed Download Row
|
||||
|
||||
private struct FailedDownloadRow: View {
|
||||
let progress: DownloadProgress
|
||||
let key: String
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(formatSlug(progress.slug))
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
Text("Chapter \(progress.chapter)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Retry button
|
||||
Button {
|
||||
Task {
|
||||
// Remove failed status
|
||||
downloadService.downloads.removeValue(forKey: key)
|
||||
// Retry download
|
||||
try? await downloadService.download(
|
||||
slug: progress.slug,
|
||||
chapter: progress.chapter,
|
||||
voice: progress.voice
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Text("Retry")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.amber.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func formatSlug(_ slug: String) -> String {
|
||||
slug.split(separator: "-")
|
||||
.map { $0.capitalized }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
216
ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift
Normal file
216
ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Downloads Management View
|
||||
// Shows all downloaded audio chapters and allows deletion
|
||||
|
||||
struct DownloadsView: View {
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var sortedDownloads: [(key: String, value: DownloadProgress)] {
|
||||
downloadService.downloads.sorted { $0.key < $1.key }
|
||||
}
|
||||
|
||||
private var totalStorageFormatted: String {
|
||||
let bytes = downloadService.getTotalStorageUsed()
|
||||
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if downloadService.downloadedChapters.isEmpty && downloadService.downloads.isEmpty {
|
||||
// Empty state
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
Text("No Downloads")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("Downloaded audio chapters will appear here for offline listening")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List {
|
||||
// Storage info section
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "internaldrive")
|
||||
.foregroundStyle(.amber)
|
||||
Text("Total Storage Used")
|
||||
Spacer()
|
||||
Text(totalStorageFormatted)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Active downloads
|
||||
if !downloadService.downloads.isEmpty {
|
||||
Section("Active Downloads") {
|
||||
ForEach(sortedDownloads, id: \.key) { key, progress in
|
||||
DownloadRow(progress: progress, key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Downloaded chapters
|
||||
if !downloadService.downloadedChapters.isEmpty {
|
||||
Section("Downloaded (\(downloadService.downloadedChapters.count))") {
|
||||
ForEach(Array(downloadService.downloadedChapters.sorted()), id: \.self) { key in
|
||||
DownloadedChapterRow(key: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all button
|
||||
if !downloadService.downloadedChapters.isEmpty {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
try? downloadService.deleteAllDownloads()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Delete All Downloads")
|
||||
.font(.subheadline.bold())
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Downloads")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download Row (in progress)
|
||||
|
||||
private struct DownloadRow: View {
|
||||
let progress: DownloadProgress
|
||||
let key: String
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Chapter \(progress.chapter)")
|
||||
.font(.subheadline.bold())
|
||||
Text(progress.slug)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if progress.status == .downloading {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(progress.progress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: progress.progress)
|
||||
.frame(width: 60)
|
||||
}
|
||||
|
||||
Button {
|
||||
downloadService.cancelDownload(slug: progress.slug, chapter: progress.chapter, voice: progress.voice)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else if case .failed(let error) = progress.status {
|
||||
VStack(alignment: .trailing) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("Failed")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downloaded Chapter Row
|
||||
|
||||
private struct DownloadedChapterRow: View {
|
||||
let key: String
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
private var components: (slug: String, chapter: String, voice: String) {
|
||||
let parts = key.split(separator: "-")
|
||||
if parts.count >= 3 {
|
||||
return (String(parts[0]), String(parts[1]), parts[2...].joined(separator: "-"))
|
||||
}
|
||||
return ("", "", "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Chapter \(components.chapter)")
|
||||
.font(.subheadline.bold())
|
||||
HStack(spacing: 4) {
|
||||
Text(components.slug)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(formatVoice(components.voice))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
let parts = components
|
||||
if let chapter = Int(parts.chapter) {
|
||||
try? downloadService.deleteDownload(slug: parts.slug, chapter: chapter, voice: parts.voice)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatVoice(_ voice: String) -> String {
|
||||
// Format voice name (e.g., "af_bella" -> "Bella (US F)")
|
||||
let parts = voice.split(separator: "_")
|
||||
guard parts.count == 2 else { return voice }
|
||||
|
||||
let prefix = String(parts[0])
|
||||
let name = String(parts[1]).capitalized
|
||||
|
||||
let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : ""
|
||||
let accent = prefix.hasPrefix("af") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : ""
|
||||
|
||||
if !gender.isEmpty && !accent.isEmpty {
|
||||
return "\(name) (\(accent) \(gender))"
|
||||
} else if !gender.isEmpty {
|
||||
return "\(name) (\(gender))"
|
||||
} else {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
451
ios/LibNovel/LibNovel/Views/Home/HomeView.swift
Normal file
451
ios/LibNovel/LibNovel/Views/Home/HomeView.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
@StateObject private var vm = HomeViewModel()
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
|
||||
private var offlineBooks: [Book] {
|
||||
let offlineSlugs = downloadService.getOfflineBookSlugs()
|
||||
// Filter continue reading items that have offline downloads
|
||||
return vm.continueReading
|
||||
.filter { offlineSlugs.contains($0.book.slug) }
|
||||
.map { $0.book }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
||||
if !vm.continueReading.isEmpty {
|
||||
ShelfHeader(title: "Continue Reading")
|
||||
.padding(.top, 8)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ContinueReadingCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ContinueReadingContextMenu(
|
||||
item: item,
|
||||
onMarkFinished: {
|
||||
Task { await markAsFinished(item.book) }
|
||||
},
|
||||
onRemove: {
|
||||
Task { await removeFromLibrary(item.book.slug) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Offline books — books with downloaded chapters
|
||||
if !offlineBooks.isEmpty {
|
||||
HStack {
|
||||
ShelfHeader(title: "Downloaded for Offline")
|
||||
Spacer()
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(offlineBooks) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ShelfBookCard(book: book)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
Text("\(downloadService.getDownloadedChapterCount(for: book.slug)) chapters")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Stats strip
|
||||
if let stats = vm.stats {
|
||||
StatsStrip(stats: stats)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Recently updated shelf
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
ShelfHeader(title: "Recently Updated")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.recentlyUpdated) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
ShelfBookCard(book: book)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Subscription feed shelf
|
||||
if !vm.subscriptionFeed.isEmpty {
|
||||
ShelfHeader(title: "From People You Follow")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.subscriptionFeed) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
SubscriptionFeedCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: item.book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "Your library is empty",
|
||||
message: "Head to Discover to find novels to read."
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
if vm.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Reading Now")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
DownloadQueueButton()
|
||||
Divider()
|
||||
.frame(height: 18)
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func shareURL(for book: Book) -> URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
let item: ContinueReadingItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard item.book.totalChapters > 0 else { return 0 }
|
||||
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||
}
|
||||
|
||||
private var progressText: String {
|
||||
let percentage = progressFraction * 100
|
||||
|
||||
// For books with many chapters, show decimal precision when less than 10%
|
||||
if percentage < 10 && percentage > 0 {
|
||||
return String(format: "%.1f%% complete", percentage)
|
||||
}
|
||||
|
||||
// Otherwise, round to nearest integer (min 1% if any progress exists)
|
||||
let rounded = max(1, Int(round(percentage)))
|
||||
return "\(rounded)% complete"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Cover
|
||||
ZStack(alignment: .bottom) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.22), radius: 8, y: 4)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// Gradient scrim so badge is always readable
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(0), Color.black.opacity(0.55)],
|
||||
startPoint: .center,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.frame(height: 60)
|
||||
|
||||
// "Continue" pill badge — centered at bottom over the scrim
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 130, alignment: .leading)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Progress bar — show at least a 4pt sliver so early chapters aren't invisible
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(Color.amber.opacity(0.9))
|
||||
.frame(width: max(4, geo.size.width * progressFraction))
|
||||
}
|
||||
}
|
||||
.frame(width: 130, height: 3)
|
||||
|
||||
// Progress label with smart rounding
|
||||
Text(progressText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: recently updated book card
|
||||
|
||||
private struct ShelfBookCard: View {
|
||||
let book: Book
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: book.slug)
|
||||
|
||||
// Chapter count badge
|
||||
Text("\(book.totalChapters) ch")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Color.black.opacity(0.55)))
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
Text(book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: subscription feed card
|
||||
|
||||
private struct SubscriptionFeedCard: View {
|
||||
let item: SubscriptionFeedItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
// Tappable "via @username" attribution
|
||||
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
|
||||
Text("via @\(item.readerUsername)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats strip (compact inline)
|
||||
|
||||
private struct StatsStrip: View {
|
||||
let stats: HomeStats
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context menus
|
||||
|
||||
private struct ContinueReadingContextMenu: View {
|
||||
let item: ContinueReadingItem
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
private var isFinished: Bool {
|
||||
guard item.book.totalChapters > 0 else { return false }
|
||||
return item.chapter >= item.book.totalChapters
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
|
||||
}
|
||||
}
|
||||
391
ios/LibNovel/LibNovel/Views/Library/LibraryView.swift
Normal file
391
ios/LibNovel/LibNovel/Views/Library/LibraryView.swift
Normal file
@@ -0,0 +1,391 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct LibraryView: View {
|
||||
@StateObject private var vm = LibraryViewModel()
|
||||
@State private var sortOrder: SortOrder = .recentlyRead
|
||||
@State private var readingFilter: ReadingFilter = .all
|
||||
@State private var selectedGenre: String = "all"
|
||||
|
||||
enum SortOrder: String, CaseIterable {
|
||||
case recentlyRead = "Recent"
|
||||
case title = "Title"
|
||||
case author = "Author"
|
||||
case progress = "Progress"
|
||||
}
|
||||
|
||||
enum ReadingFilter: String, CaseIterable {
|
||||
case all = "All"
|
||||
case inProgress = "In Progress"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
// All distinct genres across the library, sorted alphabetically.
|
||||
private var availableGenres: [String] {
|
||||
let all = vm.items.flatMap { $0.book.genres }
|
||||
let unique = Array(Set(all)).sorted()
|
||||
return unique
|
||||
}
|
||||
|
||||
private var filtered: [LibraryItem] {
|
||||
var result = vm.items
|
||||
|
||||
// 1. Reading filter
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
break
|
||||
case .inProgress:
|
||||
result = result.filter { !isCompleted($0) }
|
||||
case .completed:
|
||||
result = result.filter { isCompleted($0) }
|
||||
}
|
||||
|
||||
// 2. Genre filter
|
||||
if selectedGenre != "all" {
|
||||
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
||||
}
|
||||
|
||||
// 3. Sort
|
||||
switch sortOrder {
|
||||
case .recentlyRead:
|
||||
break // server returns by recency
|
||||
case .title:
|
||||
result = result.sorted { $0.book.title < $1.book.title }
|
||||
case .author:
|
||||
result = result.sorted { $0.book.author < $1.book.author }
|
||||
case .progress:
|
||||
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func isCompleted(_ item: LibraryItem) -> Bool {
|
||||
// Treat as completed if book status is "completed" OR
|
||||
// the user has read up to (or past) the total chapter count.
|
||||
if item.book.status.lowercased() == "completed",
|
||||
let ch = item.lastChapter,
|
||||
item.book.totalChapters > 0,
|
||||
ch >= item.book.totalChapters {
|
||||
return true
|
||||
}
|
||||
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.items.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.items.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "bookmark",
|
||||
title: "No saved books",
|
||||
message: "Books you save or start reading will appear here."
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Reading filter (All / In Progress / Completed)
|
||||
Picker("", selection: $readingFilter) {
|
||||
ForEach(ReadingFilter.allCases, id: \.self) { f in
|
||||
Text(f.rawValue).tag(f)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 16)
|
||||
|
||||
// Genre filter chips (only shown when genres are available)
|
||||
if !availableGenres.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
// "All" chip
|
||||
ChipButton(
|
||||
label: "All",
|
||||
isSelected: selectedGenre == "all",
|
||||
style: .filled
|
||||
) {
|
||||
withAnimation { selectedGenre = "all" }
|
||||
}
|
||||
ForEach(availableGenres, id: \.self) { genre in
|
||||
ChipButton(
|
||||
label: genre.capitalized,
|
||||
isSelected: selectedGenre == genre,
|
||||
style: .filled
|
||||
) {
|
||||
withAnimation {
|
||||
selectedGenre = selectedGenre == genre ? "all" : genre
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
// Sort chips
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SortOrder.allCases, id: \.self) { order in
|
||||
ChipButton(
|
||||
label: order.rawValue,
|
||||
isSelected: sortOrder == order,
|
||||
style: .outlined
|
||||
) {
|
||||
withAnimation { sortOrder = order }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
|
||||
// Book count
|
||||
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if filtered.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(emptyMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else {
|
||||
// 2-column grid (matches Discover)
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)
|
||||
],
|
||||
spacing: 14
|
||||
) {
|
||||
ForEach(filtered) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
LibraryBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
BookContextMenu(
|
||||
book: item.book,
|
||||
isFinished: isCompleted(item),
|
||||
onMarkFinished: {
|
||||
Task {
|
||||
await markAsFinished(item.book)
|
||||
}
|
||||
},
|
||||
onRemove: {
|
||||
Task {
|
||||
await removeFromLibrary(item.book.slug)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var emptyMessage: String {
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library."
|
||||
case .inProgress:
|
||||
return "No books in progress."
|
||||
case .completed:
|
||||
return "No completed books yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library book card (3-column)
|
||||
|
||||
private struct LibraryBookCard: View {
|
||||
let item: LibraryItem
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
|
||||
return Double(ch) / Double(item.book.totalChapters)
|
||||
}
|
||||
|
||||
private var isCompleted: Bool {
|
||||
progressFraction >= 1.0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
// Cover image
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay(
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// Progress arc or completed checkmark in top-right corner
|
||||
if isCompleted {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.background(Circle().fill(Color.amber).padding(1))
|
||||
.padding(6)
|
||||
} else if progressFraction > 0 {
|
||||
ProgressArc(fraction: progressFraction)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(5)
|
||||
}
|
||||
}
|
||||
|
||||
// Title + chapter badge
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.book.title)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let ch = item.lastChapter {
|
||||
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular progress arc overlay
|
||||
|
||||
private struct ProgressArc: View {
|
||||
let fraction: Double // 0...1
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: fraction)
|
||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.5), value: fraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book context menu
|
||||
|
||||
private struct BookContextMenu: View {
|
||||
let book: Book
|
||||
let isFinished: Bool
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
// Share book
|
||||
ShareLink(item: shareURL) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Mark as finished (only show if not already finished)
|
||||
if !isFinished {
|
||||
Button {
|
||||
onMarkFinished()
|
||||
} label: {
|
||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shareURL: URL {
|
||||
// Share the book detail page URL
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
}
|
||||
2060
ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift
Normal file
2060
ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user