Compare commits
26 Commits
b9b69cee44
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4831c74acc | ||
|
|
7e5e0495cf | ||
|
|
188685e1b6 | ||
|
|
3271a5f3e6 | ||
|
|
ee3ed29316 | ||
|
|
a39f660a37 | ||
|
|
69818089a6 | ||
|
|
09062b8c82 | ||
|
|
d518710cc4 | ||
|
|
e2c15f5931 | ||
|
|
a50b968b95 | ||
|
|
023b1f7fec | ||
|
|
7e99fc6d70 | ||
|
|
12d6d30fb0 | ||
|
|
f9c14685b3 | ||
|
|
4a7009989c | ||
|
|
920ac0d41b | ||
|
|
424f2c5e16 | ||
|
|
8a0f5b6cde | ||
|
|
5fea8f67d0 | ||
|
|
6592d1662c | ||
|
|
59e8cdb19a | ||
|
|
1118392811 | ||
|
|
baa403efa2 | ||
|
|
0ed1112b20 | ||
|
|
16a12ede4d |
101
.env.example
101
.env.example
@@ -1,101 +0,0 @@
|
|||||||
# 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=
|
|
||||||
|
|
||||||
# Number of concurrent browser sessions in Browserless
|
|
||||||
BROWSERLESS_CONCURRENT=10
|
|
||||||
|
|
||||||
# Queue depth before Browserless returns 429
|
|
||||||
BROWSERLESS_QUEUED=100
|
|
||||||
|
|
||||||
# Per-session timeout in ms
|
|
||||||
BROWSERLESS_TIMEOUT=60000
|
|
||||||
|
|
||||||
# Optional webhook URL for Browserless error alerts (leave empty to disable)
|
|
||||||
ERROR_ALERT_URL=
|
|
||||||
|
|
||||||
# Which Browserless strategy the scraper uses: content | scrape | cdp | direct
|
|
||||||
BROWSERLESS_STRATEGY=direct
|
|
||||||
|
|
||||||
# ── Scraper ───────────────────────────────────────────────────────────────────
|
|
||||||
# Chapter worker goroutines (0 = NumCPU inside the container)
|
|
||||||
SCRAPER_WORKERS=0
|
|
||||||
|
|
||||||
# Host path to mount as the static output directory
|
|
||||||
STATIC_ROOT=./static/books
|
|
||||||
|
|
||||||
# ── Kokoro-FastAPI TTS ────────────────────────────────────────────────────────
|
|
||||||
# Base URL for the Kokoro-FastAPI service. When running via docker-compose the
|
|
||||||
# default (http://kokoro:8880) is wired in automatically; override here only if
|
|
||||||
# you are pointing at an external or GPU instance.
|
|
||||||
KOKORO_URL=http://kokoro:8880
|
|
||||||
|
|
||||||
# Default voice used for chapter narration.
|
|
||||||
# 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
|
|
||||||
MINIO_BUCKET_BROWSE=libnovel-browse
|
|
||||||
|
|
||||||
# ── PocketBase ────────────────────────────────────────────────────────────────
|
|
||||||
# Admin credentials (used by scraper + UI server-side)
|
|
||||||
POCKETBASE_ADMIN_EMAIL=admin@libnovel.local
|
|
||||||
POCKETBASE_ADMIN_PASSWORD=changeme123
|
|
||||||
|
|
||||||
# ── SvelteKit UI ─────────────────────────────────────────────────────────────
|
|
||||||
# Internal URL the SvelteKit server uses to reach the scraper API.
|
|
||||||
# In docker-compose this is http://scraper:8080 (wired automatically).
|
|
||||||
# Override here only if running the UI outside of docker-compose.
|
|
||||||
SCRAPER_API_URL=http://localhost:8080
|
|
||||||
|
|
||||||
# Internal URL the SvelteKit server uses to reach PocketBase.
|
|
||||||
# In docker-compose this is http://pocketbase:8090 (wired automatically).
|
|
||||||
POCKETBASE_URL=http://localhost:8090
|
|
||||||
|
|
||||||
# Public MinIO URL reachable from the browser (for audio/presigned URLs).
|
|
||||||
# In production, point this at your MinIO reverse-proxy or CDN domain.
|
|
||||||
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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,146 +0,0 @@
|
|||||||
name: CI / v3
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main", "master"]
|
|
||||||
paths:
|
|
||||||
- "v3/**"
|
|
||||||
- ".gitea/workflows/ci-v3.yaml"
|
|
||||||
pull_request:
|
|
||||||
branches: ["main", "master"]
|
|
||||||
paths:
|
|
||||||
- "v3/**"
|
|
||||||
- ".gitea/workflows/ci-v3.yaml"
|
|
||||||
|
|
||||||
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: v3/backend/go.mod
|
|
||||||
cache-dependency-path: v3/backend/go.sum
|
|
||||||
|
|
||||||
- name: go vet
|
|
||||||
working-directory: v3/backend
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
working-directory: v3/backend
|
|
||||||
run: go test -short -race -count=1 -timeout=60s ./...
|
|
||||||
|
|
||||||
# ── ui: type-check ───────────────────────────────────────────────────────────
|
|
||||||
check-ui:
|
|
||||||
name: Check ui
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: v3/ui
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: v3/ui/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
# ── docker: backend (push to Docker Hub on branch push only) ─────────────────
|
|
||||||
docker-backend:
|
|
||||||
name: Docker / backend
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test-backend]
|
|
||||||
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: v3/backend
|
|
||||||
target: backend
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_USER }}/libnovel-v3-backend:latest
|
|
||||||
${{ secrets.DOCKER_USER }}/libnovel-v3-backend:${{ gitea.sha }}
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ gitea.sha }}
|
|
||||||
COMMIT=${{ gitea.sha }}
|
|
||||||
|
|
||||||
# ── docker: runner ────────────────────────────────────────────────────────────
|
|
||||||
docker-runner:
|
|
||||||
name: Docker / runner
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [test-backend]
|
|
||||||
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: v3/backend
|
|
||||||
target: runner
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_USER }}/libnovel-v3-runner:latest
|
|
||||||
${{ secrets.DOCKER_USER }}/libnovel-v3-runner:${{ gitea.sha }}
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ gitea.sha }}
|
|
||||||
COMMIT=${{ gitea.sha }}
|
|
||||||
|
|
||||||
# ── docker: ui ────────────────────────────────────────────────────────────────
|
|
||||||
docker-ui:
|
|
||||||
name: Docker / ui
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [check-ui]
|
|
||||||
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: v3/ui
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_USER }}/libnovel-v3-ui:latest
|
|
||||||
${{ secrets.DOCKER_USER }}/libnovel-v3-ui:${{ gitea.sha }}
|
|
||||||
build-args: |
|
|
||||||
BUILD_VERSION=${{ gitea.sha }}
|
|
||||||
BUILD_COMMIT=${{ gitea.sha }}
|
|
||||||
123
.gitea/workflows/ci.yaml
Normal file
123
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
- "ui/**"
|
||||||
|
- "caddy/**"
|
||||||
|
- "docker-compose.yml"
|
||||||
|
- ".gitea/workflows/ci.yaml"
|
||||||
|
pull_request:
|
||||||
|
branches: ["main", "master"]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
- "ui/**"
|
||||||
|
- "caddy/**"
|
||||||
|
- "docker-compose.yml"
|
||||||
|
- ".gitea/workflows/ci.yaml"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── backend: vet & 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: type-check & build ────────────────────────────────────────────────────
|
||||||
|
check-ui:
|
||||||
|
name: Check ui
|
||||||
|
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: validate Dockerfiles build (no push) ──────────────────────────────
|
||||||
|
docker-backend:
|
||||||
|
name: Docker / backend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-backend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: backend
|
||||||
|
target: backend
|
||||||
|
push: false
|
||||||
|
|
||||||
|
docker-runner:
|
||||||
|
name: Docker / runner
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-backend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: backend
|
||||||
|
target: runner
|
||||||
|
push: false
|
||||||
|
|
||||||
|
docker-ui:
|
||||||
|
name: Docker / ui
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-ui]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ui
|
||||||
|
push: false
|
||||||
|
|
||||||
|
docker-caddy:
|
||||||
|
name: Docker / caddy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: caddy
|
||||||
|
push: false
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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,177 +0,0 @@
|
|||||||
name: Release / v3
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v3/*" # e.g. v3/1.0.0, v3/1.2.3
|
|
||||||
- "v3-*" # e.g. v3-1.0.0 (alternative convention)
|
|
||||||
|
|
||||||
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: v3/backend/go.mod
|
|
||||||
cache-dependency-path: v3/backend/go.sum
|
|
||||||
|
|
||||||
- name: go vet
|
|
||||||
working-directory: v3/backend
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
working-directory: v3/backend
|
|
||||||
run: go test -short -race -count=1 -timeout=60s ./...
|
|
||||||
|
|
||||||
# ── ui: type-check & build ───────────────────────────────────────────────────
|
|
||||||
check-ui:
|
|
||||||
name: Check ui
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: v3/ui
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: v3/ui/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-v3-backend
|
|
||||||
tags: |
|
|
||||||
type=match,pattern=v3/(.*),group=1
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: v3/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-v3-runner
|
|
||||||
tags: |
|
|
||||||
type=match,pattern=v3/(.*),group=1
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: v3/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 ────────────────────────────────────────────────────────────────
|
|
||||||
docker-ui:
|
|
||||||
name: Docker / ui
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [check-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-v3-ui
|
|
||||||
tags: |
|
|
||||||
type=match,pattern=v3/(.*),group=1
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: v3/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 }}
|
|
||||||
|
|
||||||
# ── Gitea release ─────────────────────────────────────────────────────────────
|
|
||||||
release:
|
|
||||||
name: Gitea Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [docker-backend, docker-runner, docker-ui]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
uses: actions/gitea-release-action@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
generate_release_notes: true
|
|
||||||
274
.gitea/workflows/release.yaml
Normal file
274
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*" # e.g. v1.0.0, v1.2.3
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── backend: vet & 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: type-check & build ────────────────────────────────────────────────────
|
||||||
|
check-ui:
|
||||||
|
name: Check ui
|
||||||
|
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: backend ───────────────────────────────────────────────────────────
|
||||||
|
docker-backend:
|
||||||
|
name: Docker / backend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-backend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
|
# ── docker: runner ────────────────────────────────────────────────────────────
|
||||||
|
docker-runner:
|
||||||
|
name: Docker / runner
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-backend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
|
# ── ui: source map upload ─────────────────────────────────────────────────────
|
||||||
|
# Builds the UI with source maps and uploads them to GlitchTip so that error
|
||||||
|
# stack traces resolve to original .svelte/.ts file names and line numbers.
|
||||||
|
# Runs in parallel with docker-ui (both need check-ui to pass first).
|
||||||
|
upload-sourcemaps:
|
||||||
|
name: Upload source maps
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-ui]
|
||||||
|
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: Build with source maps
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Download glitchtip-cli
|
||||||
|
run: |
|
||||||
|
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
|
||||||
|
-o /usr/local/bin/glitchtip-cli
|
||||||
|
chmod +x /usr/local/bin/glitchtip-cli
|
||||||
|
|
||||||
|
- name: Inject debug IDs into build artifacts
|
||||||
|
run: glitchtip-cli sourcemaps inject ./build
|
||||||
|
env:
|
||||||
|
SENTRY_URL: https://errors.libnovel.cc/
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: libnovel
|
||||||
|
SENTRY_PROJECT: libnovel-ui
|
||||||
|
|
||||||
|
- name: Upload source maps to GlitchTip
|
||||||
|
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||||
|
env:
|
||||||
|
SENTRY_URL: https://errors.libnovel.cc/
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: libnovel
|
||||||
|
SENTRY_PROJECT: libnovel-ui
|
||||||
|
|
||||||
|
# ── docker: ui ────────────────────────────────────────────────────────────────
|
||||||
|
docker-ui:
|
||||||
|
name: Docker / ui
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-ui]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
|
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
|
# ── docker: caddy ─────────────────────────────────────────────────────────────
|
||||||
|
docker-caddy:
|
||||||
|
name: Docker / caddy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- 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-caddy
|
||||||
|
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: caddy
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
|
# ── Gitea release ─────────────────────────────────────────────────────────────
|
||||||
|
release:
|
||||||
|
name: Gitea Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
generate_release_notes: true
|
||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -5,16 +5,16 @@
|
|||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
||||||
scraper/bin/
|
backend/bin/
|
||||||
scraper/scraper
|
|
||||||
|
|
||||||
# ── Scraped output (large, machine-generated) ──────────────────────────────────
|
|
||||||
|
|
||||||
/static/books
|
|
||||||
# ── Environment & secrets ──────────────────────────────────────────────────────
|
# ── Environment & secrets ──────────────────────────────────────────────────────
|
||||||
|
# Secrets are managed by Doppler — never commit .env files.
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
.env.local
|
||||||
|
|
||||||
|
# ── CrowdSec — generated bouncer API key ──────────────────────────────────────
|
||||||
|
crowdsec/.crowdsec.env
|
||||||
|
|
||||||
# ── OS artefacts ───────────────────────────────────────────────────────────────
|
# ── OS artefacts ───────────────────────────────────────────────────────────────
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
182
AGENTS.md
182
AGENTS.md
@@ -1,182 +0,0 @@
|
|||||||
# libnovel Project
|
|
||||||
|
|
||||||
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 | refresh | serve | save-browse
|
|
||||||
├── internal/
|
|
||||||
│ ├── 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**: 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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
cd scraper && go build -o bin/scraper ./cmd/scraper
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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 |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `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: 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
|
|
||||||
|
|
||||||
- `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/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
|
|
||||||
|
|
||||||
## iOS App
|
|
||||||
|
|
||||||
See `ios/AGENTS.md` for full iOS/SwiftUI conventions.
|
|
||||||
|
|
||||||
## Documentation Tools
|
|
||||||
|
|
||||||
This project has two MCP-backed documentation tools available. Use them proactively:
|
|
||||||
|
|
||||||
- **`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`.
|
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
# logs.libnovel.cc → dozzle:8080 (Docker log viewer)
|
# logs.libnovel.cc → dozzle:8080 (Docker log viewer)
|
||||||
# uptime.libnovel.cc → uptime-kuma:3001 (uptime monitoring)
|
# uptime.libnovel.cc → uptime-kuma:3001 (uptime monitoring)
|
||||||
# push.libnovel.cc → gotify:80 (push notifications)
|
# push.libnovel.cc → gotify:80 (push notifications)
|
||||||
|
# search.libnovel.cc → meilisearch:7700 (search index — homelab runner)
|
||||||
#
|
#
|
||||||
# Routes intentionally removed from direct-to-backend:
|
# Routes intentionally removed from direct-to-backend:
|
||||||
# /api/scrape/* — SvelteKit has /api/scrape/ counterparts
|
# /api/scrape/* — SvelteKit has /api/scrape/ counterparts
|
||||||
@@ -43,8 +44,8 @@
|
|||||||
{
|
{
|
||||||
# Email for Let's Encrypt ACME account registration.
|
# Email for Let's Encrypt ACME account registration.
|
||||||
# When CADDY_ACME_EMAIL is set this expands to e.g. "email you@example.com".
|
# When CADDY_ACME_EMAIL is set this expands to e.g. "email you@example.com".
|
||||||
# When unset it expands to an empty string and is silently ignored.
|
# When unset the variable expands to an empty string and Caddy ignores it.
|
||||||
{$CADDY_ACME_EMAIL:}
|
email {$CADDY_ACME_EMAIL:}
|
||||||
|
|
||||||
# CrowdSec bouncer — streams decisions from the CrowdSec LAPI every 15s.
|
# CrowdSec bouncer — streams decisions from the CrowdSec LAPI every 15s.
|
||||||
# CROWDSEC_API_KEY is injected at runtime via crowdsec/.crowdsec.env.
|
# CROWDSEC_API_KEY is injected at runtime via crowdsec/.crowdsec.env.
|
||||||
@@ -203,38 +204,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Fider: user feedback & feature requests ───────────────────────────────────
|
# ── Tooling subdomains ────────────────────────────────────────────────────────
|
||||||
feedback.libnovel.cc {
|
# feedback.libnovel.cc, errors.libnovel.cc, analytics.libnovel.cc,
|
||||||
|
# logs.libnovel.cc, uptime.libnovel.cc, push.libnovel.cc, grafana.libnovel.cc
|
||||||
|
# are now routed via Cloudflare Tunnel directly to the homelab (192.168.0.109).
|
||||||
|
# No Caddy rules needed here — Cloudflare handles TLS termination and routing.
|
||||||
|
|
||||||
|
# ── PocketBase: exposed for homelab runner task polling ───────────────────────
|
||||||
|
# Allows the homelab runner to claim tasks and write results via the PB API.
|
||||||
|
# Admin UI is also accessible here for convenience.
|
||||||
|
pb.libnovel.cc {
|
||||||
import security_headers
|
import security_headers
|
||||||
reverse_proxy fider:3000
|
reverse_proxy pocketbase:8090
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── GlitchTip: error tracking ─────────────────────────────────────────────────
|
# ── MinIO S3 API: exposed for homelab runner object writes ────────────────────
|
||||||
errors.libnovel.cc {
|
# The homelab runner connects here as MINIO_ENDPOINT to PutObject audio/chapters.
|
||||||
|
# Also used as MINIO_PUBLIC_ENDPOINT for presigned URL generation.
|
||||||
|
storage.libnovel.cc {
|
||||||
import security_headers
|
import security_headers
|
||||||
reverse_proxy glitchtip-web:8000
|
reverse_proxy minio:9000
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Umami: page analytics ─────────────────────────────────────────────────────
|
# ── Meilisearch: exposed for homelab runner search indexing ──────────────────
|
||||||
analytics.libnovel.cc {
|
# The homelab runner connects here as MEILI_URL to index books after scraping.
|
||||||
|
# Protected by MEILI_MASTER_KEY bearer token — Meilisearch enforces auth on
|
||||||
|
# every request; Caddy just terminates TLS.
|
||||||
|
search.libnovel.cc {
|
||||||
import security_headers
|
import security_headers
|
||||||
reverse_proxy umami:3000
|
reverse_proxy meilisearch:7700
|
||||||
}
|
|
||||||
|
|
||||||
# ── Dozzle: Docker log viewer ─────────────────────────────────────────────────
|
|
||||||
logs.libnovel.cc {
|
|
||||||
import security_headers
|
|
||||||
reverse_proxy dozzle:8080
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Uptime Kuma: uptime monitoring ────────────────────────────────────────────
|
|
||||||
uptime.libnovel.cc {
|
|
||||||
import security_headers
|
|
||||||
reverse_proxy uptime-kuma:3001
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Gotify: push notifications ────────────────────────────────────────────────
|
|
||||||
push.libnovel.cc {
|
|
||||||
import security_headers
|
|
||||||
reverse_proxy gotify:80
|
|
||||||
}
|
}
|
||||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# LibNovel
|
||||||
|
|
||||||
|
Self-hosted audiobook platform. Go backend + SvelteKit UI + MinIO/PocketBase/Meilisearch.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- [just](https://github.com/casey/just)
|
||||||
|
- [Doppler CLI](https://docs.doppler.com/docs/install-cli)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
doppler login
|
||||||
|
doppler setup # project=libnovel, config=prd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just up # start everything
|
||||||
|
just down # stop
|
||||||
|
just logs # tail all logs
|
||||||
|
just log backend # tail one service
|
||||||
|
just build # rebuild images
|
||||||
|
just restart # down + up
|
||||||
|
just secrets # view/edit secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Managed via Doppler (`project=libnovel`, `config=prd`). No `.env` files.
|
||||||
|
|
||||||
|
To add or update a secret:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
doppler secrets set MY_SECRET=value
|
||||||
|
```
|
||||||
BIN
backend/backend
Executable file
BIN
backend/backend
Executable file
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads,
|
// It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads,
|
||||||
// scrape-task creation, presigned MinIO URLs, audio-task creation, reading
|
// scrape-task creation, presigned MinIO URLs, audio-task creation, reading
|
||||||
// progress, live novelfire.net browse/search, and Kokoro voice list.
|
// progress, live novelfire.net search, and Kokoro voice list.
|
||||||
//
|
//
|
||||||
// All heavy lifting (scraping, TTS generation) is delegated to the runner
|
// All heavy lifting (scraping, TTS generation) is delegated to the runner
|
||||||
// binary via PocketBase task records. The backend never scrapes directly.
|
// binary via PocketBase task records. The backend never scrapes directly.
|
||||||
@@ -19,10 +19,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/libnovel/backend/internal/backend"
|
"github.com/libnovel/backend/internal/backend"
|
||||||
"github.com/libnovel/backend/internal/config"
|
"github.com/libnovel/backend/internal/config"
|
||||||
"github.com/libnovel/backend/internal/kokoro"
|
"github.com/libnovel/backend/internal/kokoro"
|
||||||
|
"github.com/libnovel/backend/internal/meili"
|
||||||
|
"github.com/libnovel/backend/internal/otelsetup"
|
||||||
"github.com/libnovel/backend/internal/storage"
|
"github.com/libnovel/backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +46,19 @@ func main() {
|
|||||||
func run() error {
|
func run() error {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// ── Sentry / GlitchTip error tracking ────────────────────────────────────
|
||||||
|
if dsn := os.Getenv("GLITCHTIP_DSN"); dsn != "" {
|
||||||
|
if err := sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: dsn,
|
||||||
|
Release: version + "@" + commit,
|
||||||
|
TracesSampleRate: 0.1,
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "backend: sentry init warning: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer sentry.Flush(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Logger ───────────────────────────────────────────────────────────────
|
// ── Logger ───────────────────────────────────────────────────────────────
|
||||||
log := buildLogger(cfg.LogLevel)
|
log := buildLogger(cfg.LogLevel)
|
||||||
log.Info("backend starting",
|
log.Info("backend starting",
|
||||||
@@ -54,6 +71,19 @@ func run() error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// ── OpenTelemetry tracing + logs ──────────────────────────────────────────
|
||||||
|
otelShutdown, otelLog, err := otelsetup.Init(ctx, version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init otel: %w", err)
|
||||||
|
}
|
||||||
|
if otelShutdown != nil {
|
||||||
|
defer otelShutdown()
|
||||||
|
// Replace the plain slog logger with the OTel-bridged one so all
|
||||||
|
// structured log lines are forwarded to Loki with trace IDs attached.
|
||||||
|
log = otelLog
|
||||||
|
log.Info("otel tracing + logs enabled", "endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Storage ──────────────────────────────────────────────────────────────
|
// ── Storage ──────────────────────────────────────────────────────────────
|
||||||
store, err := storage.NewStore(ctx, cfg, log)
|
store, err := storage.NewStore(ctx, cfg, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,6 +100,16 @@ func run() error {
|
|||||||
kokoroClient = &noopKokoro{}
|
kokoroClient = &noopKokoro{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
|
||||||
|
var searchIndex meili.Client
|
||||||
|
if cfg.Meilisearch.URL != "" {
|
||||||
|
searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey)
|
||||||
|
log.Info("meilisearch search enabled", "url", cfg.Meilisearch.URL)
|
||||||
|
} else {
|
||||||
|
log.Info("MEILI_URL not set — search will use PocketBase substring fallback")
|
||||||
|
searchIndex = meili.NoopClient{}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Backend server ───────────────────────────────────────────────────────
|
// ── Backend server ───────────────────────────────────────────────────────
|
||||||
srv := backend.New(
|
srv := backend.New(
|
||||||
backend.Config{
|
backend.Config{
|
||||||
@@ -84,9 +124,10 @@ func run() error {
|
|||||||
AudioStore: store,
|
AudioStore: store,
|
||||||
PresignStore: store,
|
PresignStore: store,
|
||||||
ProgressStore: store,
|
ProgressStore: store,
|
||||||
BrowseStore: store,
|
CoverStore: store,
|
||||||
Producer: store,
|
Producer: store,
|
||||||
TaskReader: store,
|
TaskReader: store,
|
||||||
|
SearchIndex: searchIndex,
|
||||||
Kokoro: kokoroClient,
|
Kokoro: kokoroClient,
|
||||||
Log: log,
|
Log: log,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/libnovel/backend/internal/browser"
|
"github.com/libnovel/backend/internal/browser"
|
||||||
"github.com/libnovel/backend/internal/config"
|
"github.com/libnovel/backend/internal/config"
|
||||||
"github.com/libnovel/backend/internal/kokoro"
|
"github.com/libnovel/backend/internal/kokoro"
|
||||||
|
"github.com/libnovel/backend/internal/meili"
|
||||||
"github.com/libnovel/backend/internal/novelfire"
|
"github.com/libnovel/backend/internal/novelfire"
|
||||||
|
"github.com/libnovel/backend/internal/otelsetup"
|
||||||
"github.com/libnovel/backend/internal/runner"
|
"github.com/libnovel/backend/internal/runner"
|
||||||
"github.com/libnovel/backend/internal/storage"
|
"github.com/libnovel/backend/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -43,6 +46,19 @@ func main() {
|
|||||||
func run() error {
|
func run() error {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// ── Sentry / GlitchTip error tracking ────────────────────────────────────
|
||||||
|
if dsn := os.Getenv("GLITCHTIP_DSN"); dsn != "" {
|
||||||
|
if err := sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: dsn,
|
||||||
|
Release: version + "@" + commit,
|
||||||
|
TracesSampleRate: 0.1,
|
||||||
|
}); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "runner: sentry init warning: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer sentry.Flush(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Logger ──────────────────────────────────────────────────────────────
|
// ── Logger ──────────────────────────────────────────────────────────────
|
||||||
log := buildLogger(cfg.LogLevel)
|
log := buildLogger(cfg.LogLevel)
|
||||||
log.Info("runner starting",
|
log.Info("runner starting",
|
||||||
@@ -55,6 +71,19 @@ func run() error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// ── OpenTelemetry tracing + logs ─────────────────────────────────────────
|
||||||
|
otelShutdown, otelLog, err := otelsetup.Init(ctx, version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init otel: %w", err)
|
||||||
|
}
|
||||||
|
if otelShutdown != nil {
|
||||||
|
defer otelShutdown()
|
||||||
|
// Switch to the OTel-bridged logger so all structured log lines are
|
||||||
|
// forwarded to Loki with trace IDs attached.
|
||||||
|
log = otelLog
|
||||||
|
log.Info("otel tracing + logs enabled", "endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Storage ─────────────────────────────────────────────────────────────
|
// ── Storage ─────────────────────────────────────────────────────────────
|
||||||
store, err := storage.NewStore(ctx, cfg, log)
|
store, err := storage.NewStore(ctx, cfg, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,7 +103,6 @@ func run() error {
|
|||||||
browserClient := browser.NewDirectClient(browser.Config{
|
browserClient := browser.NewDirectClient(browser.Config{
|
||||||
MaxConcurrent: workers,
|
MaxConcurrent: workers,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
ProxyURL: cfg.Runner.ProxyURL,
|
|
||||||
})
|
})
|
||||||
novel := novelfire.New(browserClient, log)
|
novel := novelfire.New(browserClient, log)
|
||||||
|
|
||||||
@@ -88,20 +116,39 @@ func run() error {
|
|||||||
kokoroClient = &noopKokoro{}
|
kokoroClient = &noopKokoro{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Meilisearch ─────────────────────────────────────────────────────────
|
||||||
|
var searchIndex meili.Client
|
||||||
|
if cfg.Meilisearch.URL != "" {
|
||||||
|
if err := meili.Configure(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey); err != nil {
|
||||||
|
log.Warn("meilisearch configure failed — search indexing disabled", "err", err)
|
||||||
|
searchIndex = meili.NoopClient{}
|
||||||
|
} else {
|
||||||
|
searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey)
|
||||||
|
log.Info("meilisearch enabled", "url", cfg.Meilisearch.URL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Info("MEILI_URL not set — search indexing disabled")
|
||||||
|
searchIndex = meili.NoopClient{}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Runner ──────────────────────────────────────────────────────────────
|
// ── Runner ──────────────────────────────────────────────────────────────
|
||||||
rCfg := runner.Config{
|
rCfg := runner.Config{
|
||||||
WorkerID: cfg.Runner.WorkerID,
|
WorkerID: cfg.Runner.WorkerID,
|
||||||
PollInterval: cfg.Runner.PollInterval,
|
PollInterval: cfg.Runner.PollInterval,
|
||||||
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
|
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
|
||||||
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
|
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
|
||||||
OrchestratorWorkers: workers,
|
OrchestratorWorkers: workers,
|
||||||
|
MetricsAddr: cfg.Runner.MetricsAddr,
|
||||||
|
CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval,
|
||||||
|
SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh,
|
||||||
}
|
}
|
||||||
deps := runner.Dependencies{
|
deps := runner.Dependencies{
|
||||||
Consumer: store,
|
Consumer: store,
|
||||||
BookWriter: store,
|
BookWriter: store,
|
||||||
BookReader: store,
|
BookReader: store,
|
||||||
AudioStore: store,
|
AudioStore: store,
|
||||||
BrowseStore: store,
|
CoverStore: store,
|
||||||
|
SearchIndex: searchIndex,
|
||||||
Novel: novel,
|
Novel: novel,
|
||||||
Kokoro: kokoroClient,
|
Kokoro: kokoroClient,
|
||||||
Log: log,
|
Log: log,
|
||||||
|
|||||||
@@ -8,22 +8,52 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/getsentry/sentry-go v0.43.0 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/log v0.18.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
|
google.golang.org/grpc v1.79.2 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
|
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
|
||||||
|
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
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/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
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/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.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -13,6 +34,8 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
|
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
|
||||||
|
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
||||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
@@ -23,12 +46,44 @@ 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||||
|
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||||
|
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||||
|
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
|
||||||
|
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
|
||||||
|
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||||
|
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||||
|
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||||
|
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
@@ -39,7 +94,16 @@ 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/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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||||
|
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
Binary file not shown.
@@ -7,8 +7,7 @@ package backend
|
|||||||
// handleScrapeStatus, handleScrapeTasks
|
// handleScrapeStatus, handleScrapeTasks
|
||||||
// handleBrowse, handleSearch
|
// handleBrowse, handleSearch
|
||||||
// handleGetRanking, handleGetCover
|
// handleGetRanking, handleGetCover
|
||||||
// handleBookPreview, handleChapterText, handleReindex
|
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
|
||||||
// handleChapterText, handleReindex
|
|
||||||
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
|
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
|
||||||
// handleVoices
|
// handleVoices
|
||||||
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
|
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
|
||||||
@@ -29,6 +28,8 @@ package backend
|
|||||||
// by the runner after each catalogue scrape).
|
// by the runner after each catalogue scrape).
|
||||||
// - GET /api/book-preview returns stored data when in library, or enqueues a
|
// - 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.
|
// scrape task and returns 202 when not. The backend never scrapes directly.
|
||||||
|
// - GET /api/chapter-text-preview scrapes a chapter live from novelfire.net
|
||||||
|
// directly (no runner task, no store writes). Used for unscraped books.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -44,6 +45,9 @@ import (
|
|||||||
|
|
||||||
"github.com/libnovel/backend/internal/domain"
|
"github.com/libnovel/backend/internal/domain"
|
||||||
"github.com/libnovel/backend/internal/kokoro"
|
"github.com/libnovel/backend/internal/kokoro"
|
||||||
|
"github.com/libnovel/backend/internal/meili"
|
||||||
|
"github.com/libnovel/backend/internal/novelfire/htmlutil"
|
||||||
|
"github.com/libnovel/backend/internal/scraper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -172,82 +176,11 @@ type NovelListing struct {
|
|||||||
URL string `json:"url"`
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Try MinIO cache first ─────────────────────────────────────────────
|
|
||||||
// Only page 1 is cached; higher pages fall through to live fetch.
|
|
||||||
if pageNum == 1 && s.deps.BrowseStore != nil {
|
|
||||||
if data, ok, err := s.deps.BrowseStore.GetBrowsePage(r.Context(), genre, sortBy, status, novelType, 1); err == nil && ok {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
||||||
_, _ = w.Write(data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fall back to live novelfire.net fetch ──────────────────────────────
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d",
|
|
||||||
novelFireBase, genre, sortBy, status, novelType, pageNum)
|
|
||||||
|
|
||||||
novels, hasNext, err := s.fetchBrowsePage(ctx, targetURL)
|
|
||||||
if err != nil {
|
|
||||||
// Live fetch also failed — return empty list with cached=false flag so
|
|
||||||
// the UI can show a "not ready yet" state instead of a hard error.
|
|
||||||
s.deps.Log.Error("handleBrowse: fetch failed (no cache)", "url", targetURL, "err", err)
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
writeJSON(w, 0, map[string]any{
|
|
||||||
"novels": []any{},
|
|
||||||
"page": pageNum,
|
|
||||||
"hasNext": false,
|
|
||||||
"cached": false,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
||||||
writeJSON(w, 0, map[string]any{
|
|
||||||
"novels": novels,
|
|
||||||
"page": pageNum,
|
|
||||||
"hasNext": hasNext,
|
|
||||||
"cached": false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSearch handles GET /api/search.
|
// handleSearch handles GET /api/search.
|
||||||
// Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all")
|
// Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all")
|
||||||
|
//
|
||||||
|
// Local search is powered by Meilisearch when configured; falls back to a
|
||||||
|
// substring match against PocketBase book records otherwise.
|
||||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query().Get("q")
|
q := r.URL.Query().Get("q")
|
||||||
if len([]rune(q)) < 2 {
|
if len([]rune(q)) < 2 {
|
||||||
@@ -265,22 +198,35 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var localResults, remoteResults []NovelListing
|
var localResults, remoteResults []NovelListing
|
||||||
|
|
||||||
// Local search (PocketBase books)
|
// Local search: Meilisearch → PocketBase substring fallback
|
||||||
if source == "local" || source == "all" {
|
if source == "local" || source == "all" {
|
||||||
books, err := s.deps.BookReader.ListBooks(ctx)
|
meiliBooks, meiliErr := s.deps.SearchIndex.Search(ctx, q, 50)
|
||||||
if err != nil {
|
if meiliErr == nil && len(meiliBooks) > 0 {
|
||||||
s.deps.Log.Warn("search: ListBooks failed", "err", err)
|
for _, b := range meiliBooks {
|
||||||
|
localResults = append(localResults, NovelListing{
|
||||||
|
Slug: b.Slug,
|
||||||
|
Title: b.Title,
|
||||||
|
Cover: b.Cover,
|
||||||
|
URL: b.SourceURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
qLower := strings.ToLower(q)
|
// Fallback: substring match against PocketBase
|
||||||
for _, b := range books {
|
books, err := s.deps.BookReader.ListBooks(ctx)
|
||||||
if strings.Contains(strings.ToLower(b.Title), qLower) ||
|
if err != nil {
|
||||||
strings.Contains(strings.ToLower(b.Author), qLower) {
|
s.deps.Log.Warn("search: ListBooks failed", "err", err)
|
||||||
localResults = append(localResults, NovelListing{
|
} else {
|
||||||
Slug: b.Slug,
|
qLower := strings.ToLower(q)
|
||||||
Title: b.Title,
|
for _, b := range books {
|
||||||
Cover: b.Cover,
|
if strings.Contains(strings.ToLower(b.Title), qLower) ||
|
||||||
URL: b.SourceURL,
|
strings.Contains(strings.ToLower(b.Author), qLower) {
|
||||||
})
|
localResults = append(localResults, NovelListing{
|
||||||
|
Slug: b.Slug,
|
||||||
|
Title: b.Title,
|
||||||
|
Cover: b.Cover,
|
||||||
|
URL: b.SourceURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,18 +287,34 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleGetCover handles GET /api/cover/{domain}/{slug}.
|
// handleGetCover handles GET /api/cover/{domain}/{slug}.
|
||||||
// The new backend does not cache covers in MinIO. Instead it redirects the
|
// Serves the cover image directly from MinIO when available; falls back to a
|
||||||
// client to the novelfire.net source URL. The domain path segment is kept for
|
// redirect to the novelfire CDN when the cover has not yet been downloaded.
|
||||||
// API compatibility with the old scraper.
|
|
||||||
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||||
slug := r.PathValue("slug")
|
slug := r.PathValue("slug")
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
http.Error(w, "missing slug", http.StatusBadRequest)
|
http.Error(w, "missing slug", http.StatusBadRequest)
|
||||||
return
|
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
|
// Fast path: serve from MinIO if the cover has been downloaded.
|
||||||
// endpoint is a best-effort fallback.
|
if s.deps.CoverStore != nil {
|
||||||
|
data, ct, ok, err := s.deps.CoverStore.GetCover(r.Context(), slug)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Warn("handleGetCover: GetCover error", "slug", slug, "err", err)
|
||||||
|
}
|
||||||
|
if ok && len(data) > 0 {
|
||||||
|
if ct == "" {
|
||||||
|
ct = "image/jpeg"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: redirect to the CDN. The caller sees a working image; the
|
||||||
|
// cover will be populated on the next catalogue refresh run.
|
||||||
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
|
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
|
||||||
http.Redirect(w, r, coverURL, http.StatusFound)
|
http.Redirect(w, r, coverURL, http.StatusFound)
|
||||||
}
|
}
|
||||||
@@ -469,6 +431,117 @@ func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Fprint(w, raw)
|
fmt.Fprint(w, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}.
|
||||||
|
//
|
||||||
|
// Fetches a chapter live from novelfire.net and returns its plain text without
|
||||||
|
// writing anything to PocketBase or MinIO. This is the preview path used when
|
||||||
|
// a chapter has not yet been scraped into the library.
|
||||||
|
//
|
||||||
|
// Optional query params:
|
||||||
|
//
|
||||||
|
// chapter_url — the canonical chapter URL (preferred over constructing one)
|
||||||
|
// title — hint for the chapter title (used when the page title is empty)
|
||||||
|
//
|
||||||
|
// Response: {"slug":string,"number":int,"title":string,"text":string,"url":string}
|
||||||
|
func (s *Server) handleChapterTextPreview(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 slug or chapter number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the chapter URL to fetch.
|
||||||
|
chapterURL := r.URL.Query().Get("chapter_url")
|
||||||
|
if chapterURL == "" {
|
||||||
|
// Best-effort: novelfire chapter URLs follow /book/{slug}/chapter-{n}
|
||||||
|
chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleHint := r.URL.Query().Get("title")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Fetch the chapter page.
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chapterURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("chapter-text-preview: build request failed", "url", chapterURL, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to build request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Warn("chapter-text-preview: fetch failed", "url", chapterURL, "err", err)
|
||||||
|
jsonError(w, http.StatusBadGateway, "failed to fetch chapter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
jsonError(w, http.StatusNotFound, "chapter not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
s.deps.Log.Warn("chapter-text-preview: upstream error",
|
||||||
|
"url", chapterURL, "status", resp.StatusCode, "body_snippet", string(body))
|
||||||
|
jsonError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("chapter-text-preview: read body failed", "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to read response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse HTML and extract the #content node.
|
||||||
|
root, err := htmlutil.ParseHTML(string(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("chapter-text-preview: html parse failed", "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to parse chapter HTML")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"})
|
||||||
|
if container == nil {
|
||||||
|
s.deps.Log.Warn("chapter-text-preview: #content not found", "url", chapterURL)
|
||||||
|
jsonError(w, http.StatusNotFound, "chapter content not found on page")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownText := htmlutil.NodeToMarkdown(container)
|
||||||
|
plainText := stripMarkdown(markdownText)
|
||||||
|
|
||||||
|
// Extract the chapter title from the page <title> or <h1> if not hinted.
|
||||||
|
chapterTitle := titleHint
|
||||||
|
if chapterTitle == "" {
|
||||||
|
// Try <h1 class="chapter-title"> first, then <h2 class="chapter-title">
|
||||||
|
for _, tag := range []string{"h1", "h2", "h3"} {
|
||||||
|
if node := htmlutil.FindFirst(root, scraper.Selector{Tag: tag, Class: "chapter-title"}); node != nil {
|
||||||
|
chapterTitle = strings.TrimSpace(htmlutil.TextContent(node))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chapterTitle == "" {
|
||||||
|
chapterTitle = fmt.Sprintf("Chapter %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, 0, map[string]any{
|
||||||
|
"slug": slug,
|
||||||
|
"number": n,
|
||||||
|
"title": chapterTitle,
|
||||||
|
"text": plainText,
|
||||||
|
"url": chapterURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleReindex handles POST /api/reindex/{slug}.
|
// handleReindex handles POST /api/reindex/{slug}.
|
||||||
// Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects.
|
// Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects.
|
||||||
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -685,7 +758,13 @@ func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 0, map[string]string{"url": u})
|
writeJSON(w, 0, map[string]string{"url": u})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// voiceSampleText is the phrase synthesised for every voice sample.
|
||||||
|
const voiceSampleText = "Hello! This is a preview of what I sound like. I hope you enjoy listening to your stories with my voice."
|
||||||
|
|
||||||
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
||||||
|
// If the sample has not been generated yet it synthesises it on the fly via
|
||||||
|
// Kokoro, stores the result in MinIO, and returns the presigned URL — so the
|
||||||
|
// caller always gets a playable URL in a single request.
|
||||||
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
||||||
voice := r.PathValue("voice")
|
voice := r.PathValue("voice")
|
||||||
if voice == "" {
|
if voice == "" {
|
||||||
@@ -694,9 +773,21 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := kokoro.VoiceSampleKey(voice)
|
key := kokoro.VoiceSampleKey(voice)
|
||||||
|
|
||||||
|
// Generate sample on demand when it is not in MinIO yet.
|
||||||
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
|
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
|
||||||
http.NotFound(w, r)
|
s.deps.Log.Info("generating voice sample on demand", "voice", voice)
|
||||||
return
|
mp3, err := s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "voice sample generation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.deps.AudioStore.PutAudio(r.Context(), key, mp3); err != nil {
|
||||||
|
s.deps.Log.Error("voice sample upload failed", "voice", voice, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "voice sample upload failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
|
u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
|
||||||
@@ -708,6 +799,59 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, 0, map[string]string{"url": u})
|
writeJSON(w, 0, map[string]string{"url": u})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAvatarUpload handles PUT /api/avatar-upload/{userId}.
|
||||||
|
// The request body must be the raw image bytes; Content-Type must be
|
||||||
|
// image/jpeg, image/png, or image/webp.
|
||||||
|
//
|
||||||
|
// This endpoint is called by the SvelteKit server (not the browser directly),
|
||||||
|
// so MinIO credentials and internal networking are not a concern.
|
||||||
|
//
|
||||||
|
// Returns: { "key": "<objectKey>" }
|
||||||
|
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.PathValue("userId")
|
||||||
|
if userID == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "missing userId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
var ext string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ct, "image/jpeg"):
|
||||||
|
ext = "jpg"
|
||||||
|
case strings.HasPrefix(ct, "image/png"):
|
||||||
|
ext = "png"
|
||||||
|
case strings.HasPrefix(ct, "image/webp"):
|
||||||
|
ext = "webp"
|
||||||
|
default:
|
||||||
|
jsonError(w, http.StatusBadRequest, "unsupported content-type; use image/jpeg, image/png, or image/webp")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 5 << 20 // 5 MiB
|
||||||
|
data, err := io.ReadAll(io.LimitReader(r.Body, maxSize+1))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "failed to read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) > maxSize {
|
||||||
|
jsonError(w, http.StatusRequestEntityTooLarge, "image too large (max 5 MiB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "empty body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := s.deps.PresignStore.PutAvatar(r.Context(), userID, ext, ct, data)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("avatar upload failed", "userId", userID, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "upload failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 0, map[string]string{"key": key})
|
||||||
|
}
|
||||||
|
|
||||||
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
|
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
|
||||||
// Query params: ext (jpg|png|webp, defaults to jpg)
|
// Query params: ext (jpg|png|webp, defaults to jpg)
|
||||||
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -826,6 +970,82 @@ func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 0, map[string]string{})
|
writeJSON(w, 0, map[string]string{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Catalogue (Meilisearch-backed browse + search) ────────────────────────────
|
||||||
|
|
||||||
|
// handleCatalogue handles GET /api/catalogue.
|
||||||
|
//
|
||||||
|
// Provides unified browse + search over the locally-indexed book catalogue
|
||||||
|
// via Meilisearch. Unlike /api/browse this never fetches novelfire.net live —
|
||||||
|
// it is entirely served from the Meilisearch index populated by the runner.
|
||||||
|
//
|
||||||
|
// Query params:
|
||||||
|
//
|
||||||
|
// q — full-text search query (optional)
|
||||||
|
// genre — genre filter, e.g. "fantasy" or "all" (default "all")
|
||||||
|
// status — status filter: "ongoing", "completed", or "all" (default "all")
|
||||||
|
// sort — "popular" (default) | "new" | "top-rated" | "rank"
|
||||||
|
// page — 1-indexed page number (default 1)
|
||||||
|
// limit — items per page (default 20, max 100)
|
||||||
|
func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
genre := q.Get("genre")
|
||||||
|
if genre == "" {
|
||||||
|
genre = "all"
|
||||||
|
}
|
||||||
|
status := q.Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
sort := q.Get("sort")
|
||||||
|
if sort == "" {
|
||||||
|
sort = "popular"
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(q.Get("page"))
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
cq := meili.CatalogueQuery{
|
||||||
|
Q: q.Get("q"),
|
||||||
|
Genre: genre,
|
||||||
|
Status: status,
|
||||||
|
Sort: sort,
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Log.Error("handleCatalogue: Catalogue query failed", "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNext := int64(page*limit) < total
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||||
|
writeJSON(w, 0, map[string]any{
|
||||||
|
"books": books,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total": total,
|
||||||
|
"has_next": hasNext,
|
||||||
|
"facets": map[string]any{
|
||||||
|
"genres": facets.Genres,
|
||||||
|
"statuses": facets.Statuses,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Browse page parsing helpers ────────────────────────────────────────────────
|
// ── Browse page parsing helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
// fetchBrowsePage fetches pageURL and parses NovelListings from the HTML.
|
// fetchBrowsePage fetches pageURL and parses NovelListings from the HTML.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// picks up and executes those tasks asynchronously
|
// picks up and executes those tasks asynchronously
|
||||||
// - Presigned MinIO URLs for media playback/upload
|
// - Presigned MinIO URLs for media playback/upload
|
||||||
// - Session-scoped reading progress
|
// - Session-scoped reading progress
|
||||||
// - Live novelfire.net browse/search (no scraper interface needed; direct HTTP)
|
// - Live novelfire.net search (no scraper interface needed; direct HTTP)
|
||||||
// - Kokoro voice list
|
// - Kokoro voice list
|
||||||
//
|
//
|
||||||
// The backend never scrapes directly. All scraping (metadata, chapter list,
|
// The backend never scrapes directly. All scraping (metadata, chapter list,
|
||||||
@@ -28,9 +28,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/libnovel/backend/internal/bookstore"
|
"github.com/libnovel/backend/internal/bookstore"
|
||||||
"github.com/libnovel/backend/internal/kokoro"
|
"github.com/libnovel/backend/internal/kokoro"
|
||||||
|
"github.com/libnovel/backend/internal/meili"
|
||||||
"github.com/libnovel/backend/internal/taskqueue"
|
"github.com/libnovel/backend/internal/taskqueue"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dependencies holds all external services the backend server depends on.
|
// Dependencies holds all external services the backend server depends on.
|
||||||
@@ -46,12 +49,16 @@ type Dependencies struct {
|
|||||||
PresignStore bookstore.PresignStore
|
PresignStore bookstore.PresignStore
|
||||||
// ProgressStore reads/writes per-session reading progress.
|
// ProgressStore reads/writes per-session reading progress.
|
||||||
ProgressStore bookstore.ProgressStore
|
ProgressStore bookstore.ProgressStore
|
||||||
// BrowseStore reads cached browse page snapshots from MinIO.
|
// CoverStore reads and writes book cover images from MinIO.
|
||||||
BrowseStore bookstore.BrowseStore
|
// If nil, the cover endpoint falls back to a CDN redirect.
|
||||||
|
CoverStore bookstore.CoverStore
|
||||||
// Producer creates scrape/audio tasks in PocketBase.
|
// Producer creates scrape/audio tasks in PocketBase.
|
||||||
Producer taskqueue.Producer
|
Producer taskqueue.Producer
|
||||||
// TaskReader reads scrape/audio task records from PocketBase.
|
// TaskReader reads scrape/audio task records from PocketBase.
|
||||||
TaskReader taskqueue.Reader
|
TaskReader taskqueue.Reader
|
||||||
|
// SearchIndex provides full-text book search via Meilisearch.
|
||||||
|
// If nil, the local-only fallback search is used.
|
||||||
|
SearchIndex meili.Client
|
||||||
// Kokoro is the TTS client (used for voice list only in the backend;
|
// Kokoro is the TTS client (used for voice list only in the backend;
|
||||||
// audio generation is done by the runner).
|
// audio generation is done by the runner).
|
||||||
Kokoro kokoro.Client
|
Kokoro kokoro.Client
|
||||||
@@ -88,6 +95,9 @@ func New(cfg Config, deps Dependencies) *Server {
|
|||||||
if deps.Log == nil {
|
if deps.Log == nil {
|
||||||
deps.Log = slog.Default()
|
deps.Log = slog.Default()
|
||||||
}
|
}
|
||||||
|
if deps.SearchIndex == nil {
|
||||||
|
deps.SearchIndex = meili.NoopClient{}
|
||||||
|
}
|
||||||
return &Server{cfg: cfg, deps: deps}
|
return &Server{cfg: cfg, deps: deps}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +122,12 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
// Cancel a pending task (scrape or audio)
|
// Cancel a pending task (scrape or audio)
|
||||||
mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask)
|
mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask)
|
||||||
|
|
||||||
// Browse & search (live novelfire.net)
|
// Browse & search
|
||||||
mux.HandleFunc("GET /api/browse", s.handleBrowse)
|
|
||||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||||
|
|
||||||
|
// Catalogue (Meilisearch-backed browse + search — preferred path for UI)
|
||||||
|
mux.HandleFunc("GET /api/catalogue", s.handleCatalogue)
|
||||||
|
|
||||||
// Ranking (from PocketBase)
|
// Ranking (from PocketBase)
|
||||||
mux.HandleFunc("GET /api/ranking", s.handleGetRanking)
|
mux.HandleFunc("GET /api/ranking", s.handleGetRanking)
|
||||||
|
|
||||||
@@ -131,6 +143,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
// Use this instead of presign+fetch to avoid SvelteKit→MinIO network path.
|
// Use this instead of presign+fetch to avoid SvelteKit→MinIO network path.
|
||||||
mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown)
|
mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown)
|
||||||
|
|
||||||
|
// Chapter text preview — live scrape from novelfire.net, no store writes.
|
||||||
|
// Used when the chapter is not yet in the library (preview mode).
|
||||||
|
mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview)
|
||||||
|
|
||||||
// Reindex chapters_idx from MinIO
|
// Reindex chapters_idx from MinIO
|
||||||
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
||||||
|
|
||||||
@@ -148,15 +164,24 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
|||||||
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
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-upload/{userId}", s.handlePresignAvatarUpload)
|
||||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||||
|
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||||
|
|
||||||
// Reading progress
|
// Reading progress
|
||||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||||
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
|
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
|
||||||
|
|
||||||
|
// Wrap mux with OTel tracing (no-op when no TracerProvider is set),
|
||||||
|
// then with Sentry for panic recovery and error reporting.
|
||||||
|
var handler http.Handler = mux
|
||||||
|
handler = otelhttp.NewHandler(handler, "libnovel.backend",
|
||||||
|
otelhttp.WithMessageEvents(otelhttp.ReadEvents, otelhttp.WriteEvents),
|
||||||
|
)
|
||||||
|
handler = sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle(handler)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: s.cfg.Addr,
|
Addr: s.cfg.Addr,
|
||||||
Handler: mux,
|
Handler: handler,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 60 * time.Second,
|
WriteTimeout: 60 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ type PresignStore interface {
|
|||||||
// Returns ("", false, nil) when no avatar exists.
|
// Returns ("", false, nil) when no avatar exists.
|
||||||
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
|
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
|
||||||
|
|
||||||
|
// PutAvatar stores raw image bytes for a user avatar directly in MinIO.
|
||||||
|
// ext should be "jpg", "png", or "webp". Returns the object key.
|
||||||
|
PutAvatar(ctx context.Context, userID, ext, contentType string, data []byte) (key string, err error)
|
||||||
|
|
||||||
// DeleteAvatar removes all avatar objects for a user.
|
// DeleteAvatar removes all avatar objects for a user.
|
||||||
DeleteAvatar(ctx context.Context, userID string) error
|
DeleteAvatar(ctx context.Context, userID string) error
|
||||||
}
|
}
|
||||||
@@ -124,14 +128,16 @@ type ProgressStore interface {
|
|||||||
DeleteProgress(ctx context.Context, sessionID, slug string) error
|
DeleteProgress(ctx context.Context, sessionID, slug string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrowseStore covers browse page snapshot storage.
|
// CoverStore covers book cover image storage in MinIO.
|
||||||
// The runner writes snapshots; the backend reads them.
|
// The runner writes covers during catalogue refresh; the backend reads them.
|
||||||
type BrowseStore interface {
|
type CoverStore interface {
|
||||||
// PutBrowsePage stores a raw JSON snapshot for a browse page.
|
// PutCover stores a raw cover image for a book identified by slug.
|
||||||
// genre, sort, status, novelType and page identify the page.
|
PutCover(ctx context.Context, slug string, data []byte, contentType string) error
|
||||||
PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error
|
|
||||||
|
|
||||||
// GetBrowsePage retrieves a raw JSON snapshot. Returns (nil, false, nil)
|
// GetCover retrieves the cover image for a book. Returns (nil, false, nil)
|
||||||
// when no snapshot exists for the given parameters.
|
// when no cover exists for the given slug.
|
||||||
GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error)
|
GetCover(ctx context.Context, slug string) ([]byte, string, bool, error)
|
||||||
|
|
||||||
|
// CoverExists returns true when a cover image is stored for slug.
|
||||||
|
CoverExists(ctx context.Context, slug string) bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ func (m *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string,
|
|||||||
func (m *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
|
func (m *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockStore) PutAvatar(_ context.Context, _, _, _ string, _ []byte) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
func (m *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
|
func (m *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
|
||||||
|
|
||||||
// ProgressStore
|
// ProgressStore
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
// Package browser provides a rate-limited HTTP client for web scraping.
|
// 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
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -51,9 +47,6 @@ type Config struct {
|
|||||||
MaxConcurrent int
|
MaxConcurrent int
|
||||||
// Timeout is the per-request deadline. Defaults to 90s when 0.
|
// Timeout is the per-request deadline. Defaults to 90s when 0.
|
||||||
Timeout time.Duration
|
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.
|
// DirectClient is a plain net/http-based Client with a concurrency semaphore.
|
||||||
@@ -75,14 +68,6 @@ func NewDirectClient(cfg Config) *DirectClient {
|
|||||||
MaxIdleConnsPerHost: cfg.MaxConcurrent * 2,
|
MaxIdleConnsPerHost: cfg.MaxConcurrent * 2,
|
||||||
DisableCompression: false,
|
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{
|
return &DirectClient{
|
||||||
http: &http.Client{
|
http: &http.Client{
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ type HTTP struct {
|
|||||||
Addr string
|
Addr string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meilisearch holds connection settings for the Meilisearch full-text search service.
|
||||||
|
type Meilisearch struct {
|
||||||
|
// URL is the base URL of the Meilisearch instance, e.g. http://localhost:7700
|
||||||
|
// An empty string disables Meilisearch indexing and search.
|
||||||
|
URL string
|
||||||
|
// APIKey is the Meilisearch master/search API key.
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valkey holds connection settings for the Valkey/Redis presign URL cache.
|
||||||
|
type Valkey struct {
|
||||||
|
// Addr is the host:port of the Valkey instance, e.g. localhost:6379
|
||||||
|
// An empty string disables the Valkey cache (falls through to MinIO directly).
|
||||||
|
Addr string
|
||||||
|
}
|
||||||
|
|
||||||
// Runner holds settings specific to the runner/worker binary.
|
// Runner holds settings specific to the runner/worker binary.
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
// PollInterval is how often the runner checks PocketBase for pending tasks.
|
// PollInterval is how often the runner checks PocketBase for pending tasks.
|
||||||
@@ -78,17 +94,29 @@ type Runner struct {
|
|||||||
Workers int
|
Workers int
|
||||||
// Timeout is the per-request HTTP timeout for scraping.
|
// Timeout is the per-request HTTP timeout for scraping.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// ProxyURL is an optional outbound proxy for scraper HTTP requests.
|
// MetricsAddr is the listen address for the runner /metrics HTTP endpoint.
|
||||||
ProxyURL string
|
// Defaults to ":9091". Set to "" to disable.
|
||||||
|
MetricsAddr string
|
||||||
|
// CatalogueRefreshInterval is how often the runner walks the full catalogue,
|
||||||
|
// scrapes per-book metadata, downloads covers, and re-indexes in Meilisearch.
|
||||||
|
// Defaults to 24h. Set to 0 to use the default.
|
||||||
|
CatalogueRefreshInterval time.Duration
|
||||||
|
// SkipInitialCatalogueRefresh prevents the runner from running a full
|
||||||
|
// catalogue walk on startup. Useful for quick restarts where the catalogue
|
||||||
|
// is already indexed and a 24h walk would be wasteful.
|
||||||
|
// Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true.
|
||||||
|
SkipInitialCatalogueRefresh bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config is the top-level configuration struct consumed by both binaries.
|
// Config is the top-level configuration struct consumed by both binaries.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
PocketBase PocketBase
|
PocketBase PocketBase
|
||||||
MinIO MinIO
|
MinIO MinIO
|
||||||
Kokoro Kokoro
|
Kokoro Kokoro
|
||||||
HTTP HTTP
|
HTTP HTTP
|
||||||
Runner Runner
|
Runner Runner
|
||||||
|
Meilisearch Meilisearch
|
||||||
|
Valkey Valkey
|
||||||
// LogLevel is one of "debug", "info", "warn", "error".
|
// LogLevel is one of "debug", "info", "warn", "error".
|
||||||
LogLevel string
|
LogLevel string
|
||||||
}
|
}
|
||||||
@@ -117,10 +145,10 @@ func Load() Config {
|
|||||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||||
UseSSL: envBool("MINIO_USE_SSL", false),
|
UseSSL: envBool("MINIO_USE_SSL", false),
|
||||||
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
|
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
|
||||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
|
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"),
|
||||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
|
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"),
|
||||||
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
|
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"),
|
||||||
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
|
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"),
|
||||||
},
|
},
|
||||||
|
|
||||||
Kokoro: Kokoro{
|
Kokoro: Kokoro{
|
||||||
@@ -133,13 +161,24 @@ func Load() Config {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Runner: Runner{
|
Runner: Runner{
|
||||||
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
|
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
|
||||||
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
|
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
|
||||||
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
|
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
|
||||||
WorkerID: envOr("RUNNER_WORKER_ID", workerID),
|
WorkerID: envOr("RUNNER_WORKER_ID", workerID),
|
||||||
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
|
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
|
||||||
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
|
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
|
||||||
ProxyURL: envOr("SCRAPER_PROXY", ""),
|
MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"),
|
||||||
|
CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0),
|
||||||
|
SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false),
|
||||||
|
},
|
||||||
|
|
||||||
|
Meilisearch: Meilisearch{
|
||||||
|
URL: envOr("MEILI_URL", ""),
|
||||||
|
APIKey: envOr("MEILI_API_KEY", ""),
|
||||||
|
},
|
||||||
|
|
||||||
|
Valkey: Valkey{
|
||||||
|
Addr: envOr("VALKEY_ADDR", ""),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestLoad_Defaults(t *testing.T) {
|
|||||||
"KOKORO_URL", "KOKORO_VOICE",
|
"KOKORO_URL", "KOKORO_VOICE",
|
||||||
"BACKEND_HTTP_ADDR",
|
"BACKEND_HTTP_ADDR",
|
||||||
"RUNNER_POLL_INTERVAL", "RUNNER_MAX_CONCURRENT_SCRAPE", "RUNNER_MAX_CONCURRENT_AUDIO",
|
"RUNNER_POLL_INTERVAL", "RUNNER_MAX_CONCURRENT_SCRAPE", "RUNNER_MAX_CONCURRENT_AUDIO",
|
||||||
"RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT", "SCRAPER_PROXY",
|
"RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT",
|
||||||
}
|
}
|
||||||
for _, k := range unset {
|
for _, k := range unset {
|
||||||
t.Setenv(k, "")
|
t.Setenv(k, "")
|
||||||
@@ -33,8 +33,8 @@ func TestLoad_Defaults(t *testing.T) {
|
|||||||
if cfg.PocketBase.URL != "http://localhost:8090" {
|
if cfg.PocketBase.URL != "http://localhost:8090" {
|
||||||
t.Errorf("PocketBase.URL: want http://localhost:8090, got %q", cfg.PocketBase.URL)
|
t.Errorf("PocketBase.URL: want http://localhost:8090, got %q", cfg.PocketBase.URL)
|
||||||
}
|
}
|
||||||
if cfg.MinIO.BucketChapters != "libnovel-chapters" {
|
if cfg.MinIO.BucketChapters != "chapters" {
|
||||||
t.Errorf("MinIO.BucketChapters: want libnovel-chapters, got %q", cfg.MinIO.BucketChapters)
|
t.Errorf("MinIO.BucketChapters: want chapters, got %q", cfg.MinIO.BucketChapters)
|
||||||
}
|
}
|
||||||
if cfg.MinIO.UseSSL != false {
|
if cfg.MinIO.UseSSL != false {
|
||||||
t.Errorf("MinIO.UseSSL: want false, got %v", cfg.MinIO.UseSSL)
|
t.Errorf("MinIO.UseSSL: want false, got %v", cfg.MinIO.UseSSL)
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ type BookMeta struct {
|
|||||||
TotalChapters int `json:"total_chapters,omitempty"`
|
TotalChapters int `json:"total_chapters,omitempty"`
|
||||||
SourceURL string `json:"source_url"`
|
SourceURL string `json:"source_url"`
|
||||||
Ranking int `json:"ranking,omitempty"`
|
Ranking int `json:"ranking,omitempty"`
|
||||||
|
Rating float64 `json:"rating,omitempty"`
|
||||||
|
// MetaUpdated is the Unix timestamp (seconds) when the book record was last
|
||||||
|
// updated in PocketBase. Populated on read; not sent on write (PocketBase
|
||||||
|
// manages its own updated field).
|
||||||
|
MetaUpdated int64 `json:"meta_updated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
|
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
|
||||||
type CatalogueEntry struct {
|
type CatalogueEntry struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case entries <- domain.CatalogueEntry{Title: title, URL: bookURL}:
|
case entries <- domain.CatalogueEntry{Slug: slugFromURL(bookURL), Title: title, URL: bookURL}:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +194,12 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.Bo
|
|||||||
|
|
||||||
// ── ChapterListProvider ───────────────────────────────────────────────────────
|
// ── ChapterListProvider ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ScrapeChapterList returns all chapter references for a book, ordered ascending.
|
// ScrapeChapterList returns chapter references for a book, ordered ascending.
|
||||||
func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) {
|
// upTo > 0 stops pagination as soon as at least upTo chapter numbers have been
|
||||||
|
// collected — use this for range scrapes so we don't paginate 100 pages just
|
||||||
|
// to discover refs we'll never scrape. upTo == 0 fetches all pages.
|
||||||
|
// Each page fetch uses retryGet with 429-aware exponential backoff.
|
||||||
|
func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo int) ([]domain.ChapterRef, error) {
|
||||||
var refs []domain.ChapterRef
|
var refs []domain.ChapterRef
|
||||||
baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters"
|
baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters"
|
||||||
page := 1
|
page := 1
|
||||||
@@ -210,7 +214,7 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]doma
|
|||||||
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
|
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
|
||||||
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
|
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
|
||||||
|
|
||||||
raw, err := s.client.GetContent(ctx, pageURL)
|
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return refs, fmt.Errorf("chapter list page %d: %w", page, err)
|
return refs, fmt.Errorf("chapter list page %d: %w", page, err)
|
||||||
}
|
}
|
||||||
@@ -255,6 +259,13 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]doma
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early-stop: if we have seen at least upTo chapter numbers, we have
|
||||||
|
// enough refs to cover the requested range — no need to paginate further.
|
||||||
|
if upTo > 0 && len(refs) > 0 && refs[len(refs)-1].Number >= upTo {
|
||||||
|
s.log.Debug("chapter list early-stop reached", "upTo", upTo, "collected", len(refs))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
page++
|
page++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// - RunBook scrapes one book (metadata + chapter list + chapter texts) end-to-end.
|
// - 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.
|
// - 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.
|
// - The caller (runner poll loop) owns the outer task-claim / finish cycle.
|
||||||
|
// - An optional PostMetadata hook (set in Config) is called after WriteMetadata
|
||||||
|
// succeeds. The runner uses this to upsert books into Meilisearch.
|
||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -25,14 +27,19 @@ type Config struct {
|
|||||||
// Workers is the number of goroutines used to scrape chapters in parallel.
|
// Workers is the number of goroutines used to scrape chapters in parallel.
|
||||||
// Defaults to runtime.NumCPU() when 0.
|
// Defaults to runtime.NumCPU() when 0.
|
||||||
Workers int
|
Workers int
|
||||||
|
// PostMetadata is an optional hook called with the scraped BookMeta after
|
||||||
|
// WriteMetadata succeeds. Errors from the hook are logged but not fatal.
|
||||||
|
// Used by the runner to index books in Meilisearch.
|
||||||
|
PostMetadata func(ctx context.Context, meta domain.BookMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orchestrator runs a single-book scrape pipeline.
|
// Orchestrator runs a single-book scrape pipeline.
|
||||||
type Orchestrator struct {
|
type Orchestrator struct {
|
||||||
novel scraper.NovelScraper
|
novel scraper.NovelScraper
|
||||||
store bookstore.BookWriter
|
store bookstore.BookWriter
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
workers int
|
workers int
|
||||||
|
postMetadata func(ctx context.Context, meta domain.BookMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new Orchestrator.
|
// New returns a new Orchestrator.
|
||||||
@@ -44,7 +51,13 @@ func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log
|
|||||||
if workers <= 0 {
|
if workers <= 0 {
|
||||||
workers = runtime.NumCPU()
|
workers = runtime.NumCPU()
|
||||||
}
|
}
|
||||||
return &Orchestrator{novel: novel, store: store, log: log, workers: workers}
|
return &Orchestrator{
|
||||||
|
novel: novel,
|
||||||
|
store: store,
|
||||||
|
log: log,
|
||||||
|
workers: workers,
|
||||||
|
postMetadata: cfg.PostMetadata,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunBook scrapes a single book described by task. It handles:
|
// RunBook scrapes a single book described by task. It handles:
|
||||||
@@ -84,12 +97,16 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
|
|||||||
result.Errors++
|
result.Errors++
|
||||||
} else {
|
} else {
|
||||||
result.BooksFound = 1
|
result.BooksFound = 1
|
||||||
|
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
|
||||||
|
if o.postMetadata != nil {
|
||||||
|
o.postMetadata(ctx, meta)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
||||||
|
|
||||||
// ── Step 2: Chapter list ──────────────────────────────────────────────────
|
// ── Step 2: Chapter list ──────────────────────────────────────────────────
|
||||||
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL)
|
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
|
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
|
||||||
result.ErrorMessage = fmt.Sprintf("chapter list: %v", err)
|
result.ErrorMessage = fmt.Sprintf("chapter list: %v", err)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (s *stubScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMe
|
|||||||
return s.meta, s.metaErr
|
return s.meta, s.metaErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) {
|
func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string, _ int) ([]domain.ChapterRef, error) {
|
||||||
return s.refs, s.refsErr
|
return s.refs, s.refsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
backend/internal/otelsetup/otelsetup.go
Normal file
108
backend/internal/otelsetup/otelsetup.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Package otelsetup initialises the OpenTelemetry SDK for the LibNovel backend.
|
||||||
|
//
|
||||||
|
// It reads two environment variables:
|
||||||
|
//
|
||||||
|
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP/HTTP endpoint, e.g. http://otel-collector:4318
|
||||||
|
// OTEL_SERVICE_NAME — service name reported in traces (default: "backend")
|
||||||
|
//
|
||||||
|
// When OTEL_EXPORTER_OTLP_ENDPOINT is empty the function is a no-op: it
|
||||||
|
// returns a nil shutdown func and the default slog.Logger, so callers never
|
||||||
|
// need to branch on it.
|
||||||
|
//
|
||||||
|
// Usage in main.go:
|
||||||
|
//
|
||||||
|
// shutdown, log, err := otelsetup.Init(ctx, version)
|
||||||
|
// if err != nil { return err }
|
||||||
|
// if shutdown != nil { defer shutdown() }
|
||||||
|
package otelsetup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||||
|
otellog "go.opentelemetry.io/otel/log/global"
|
||||||
|
"go.opentelemetry.io/otel/sdk/log"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init sets up TracerProvider and LoggerProvider that export via OTLP/HTTP.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - shutdown: flushes and stops both providers (nil when OTel is disabled).
|
||||||
|
// - logger: an slog.Logger bridged to OTel logs (falls back to default when disabled).
|
||||||
|
// - err: non-nil only on SDK initialisation failure.
|
||||||
|
func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Logger, err error) {
|
||||||
|
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, slog.Default(), nil // OTel disabled — not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceName := os.Getenv("OTEL_SERVICE_NAME")
|
||||||
|
if serviceName == "" {
|
||||||
|
serviceName = "backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared resource ───────────────────────────────────────────────────────
|
||||||
|
res, err := resource.New(ctx,
|
||||||
|
resource.WithAttributes(
|
||||||
|
semconv.ServiceName(serviceName),
|
||||||
|
semconv.ServiceVersion(version),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, slog.Default(), fmt.Errorf("otelsetup: create resource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trace provider ────────────────────────────────────────────────────────
|
||||||
|
traceExp, err := otlptracehttp.New(ctx,
|
||||||
|
otlptracehttp.WithEndpoint(endpoint),
|
||||||
|
otlptracehttp.WithInsecure(), // collector is on the internal Docker network
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP trace exporter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tp := sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithBatcher(traceExp),
|
||||||
|
sdktrace.WithResource(res),
|
||||||
|
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.2))),
|
||||||
|
)
|
||||||
|
otel.SetTracerProvider(tp)
|
||||||
|
|
||||||
|
// ── Log provider ──────────────────────────────────────────────────────────
|
||||||
|
logExp, err := otlploghttp.New(ctx,
|
||||||
|
otlploghttp.WithEndpoint(endpoint),
|
||||||
|
otlploghttp.WithInsecure(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP log exporter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lp := log.NewLoggerProvider(
|
||||||
|
log.WithProcessor(log.NewBatchProcessor(logExp)),
|
||||||
|
log.WithResource(res),
|
||||||
|
)
|
||||||
|
otellog.SetLoggerProvider(lp)
|
||||||
|
|
||||||
|
// Bridge slog → OTel logs. Structured fields and trace IDs are forwarded
|
||||||
|
// automatically; Grafana can correlate log lines with Tempo traces.
|
||||||
|
otelLogger := otelslog.NewLogger(serviceName)
|
||||||
|
|
||||||
|
shutdown = func() {
|
||||||
|
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = tp.Shutdown(shutCtx)
|
||||||
|
_ = lp.Shutdown(shutCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return shutdown, otelLogger, nil
|
||||||
|
}
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package runner
|
|
||||||
|
|
||||||
// browse_refresh.go — independent 6-hour loop that fetches novelfire.net
|
|
||||||
// browse page snapshots and stores them in MinIO.
|
|
||||||
//
|
|
||||||
// Design:
|
|
||||||
// - Runs on its own ticker (BrowseRefreshInterval, default 6h) inside Run().
|
|
||||||
// - Fetches page 1 for each combination of the standard genre/sort/status
|
|
||||||
// filter values and stores the parsed JSON blob in MinIO via BrowseStore.
|
|
||||||
// - The backend's handleBrowse then serves from MinIO instead of calling
|
|
||||||
// novelfire.net live, which avoids IP-based rate-limiting on the server.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// browseNovelListing mirrors backend.NovelListing for JSON serialisation.
|
|
||||||
type browseNovelListing struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Cover string `json:"cover"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseSnapshot is the JSON structure stored in MinIO.
|
|
||||||
type browseSnapshot struct {
|
|
||||||
Novels []browseNovelListing `json:"novels"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
HasNext bool `json:"hasNext"`
|
|
||||||
// CachedAt is the UTC time the snapshot was written (ISO 8601).
|
|
||||||
CachedAt string `json:"cachedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseCombos lists the filter combinations to pre-fetch.
|
|
||||||
// Each entry is (genre, sort, status, novelType).
|
|
||||||
var browseCombos = []struct{ genre, sort, status, novelType string }{
|
|
||||||
{"all", "popular", "all", "all-novel"},
|
|
||||||
{"all", "popular", "ongoing", "all-novel"},
|
|
||||||
{"all", "popular", "completed", "all-novel"},
|
|
||||||
{"all", "new", "all", "all-novel"},
|
|
||||||
{"all", "new", "ongoing", "all-novel"},
|
|
||||||
{"all", "new", "completed", "all-novel"},
|
|
||||||
{"all", "top-rated", "all", "all-novel"},
|
|
||||||
{"all", "top-rated", "ongoing", "all-novel"},
|
|
||||||
{"all", "top-rated", "completed", "all-novel"},
|
|
||||||
}
|
|
||||||
|
|
||||||
const novelFireBrowseBase = "https://novelfire.net"
|
|
||||||
|
|
||||||
// runBrowseRefresh fetches all browse combos from novelfire.net and stores
|
|
||||||
// the results in MinIO. Errors per-combo are logged but do not abort the
|
|
||||||
// whole refresh cycle.
|
|
||||||
func (r *Runner) runBrowseRefresh(ctx context.Context) {
|
|
||||||
if r.deps.BrowseStore == nil {
|
|
||||||
r.deps.Log.Warn("runner: browse refresh skipped — BrowseStore not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log := r.deps.Log.With("op", "browse_refresh")
|
|
||||||
log.Info("runner: browse refresh starting", "combos", len(browseCombos))
|
|
||||||
|
|
||||||
ok, fail := 0, 0
|
|
||||||
for _, c := range browseCombos {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
novels, hasNext, err := fetchBrowsePage(ctx, c.genre, c.sort, c.status, c.novelType)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("runner: browse fetch failed",
|
|
||||||
"genre", c.genre, "sort", c.sort, "status", c.status, "err", err)
|
|
||||||
fail++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
snap := browseSnapshot{
|
|
||||||
Novels: novels,
|
|
||||||
Page: 1,
|
|
||||||
HasNext: hasNext,
|
|
||||||
CachedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(snap)
|
|
||||||
if err := r.deps.BrowseStore.PutBrowsePage(ctx, c.genre, c.sort, c.status, c.novelType, 1, data); err != nil {
|
|
||||||
log.Warn("runner: browse put failed",
|
|
||||||
"genre", c.genre, "sort", c.sort, "status", c.status, "err", err)
|
|
||||||
fail++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("runner: browse refresh finished", "ok", ok, "failed", fail)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchBrowsePage calls novelfire.net and returns a list of novel listings
|
|
||||||
// plus a hasNext flag. Mirrors the logic in backend/handlers.go.
|
|
||||||
func fetchBrowsePage(ctx context.Context, genre, sort, status, novelType string) ([]browseNovelListing, bool, error) {
|
|
||||||
pageURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=1",
|
|
||||||
novelFireBrowseBase, genre, sort, status, novelType)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
|
|
||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
||||||
|
|
||||||
httpClient := &http.Client{Timeout: 45 * time.Second}
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("fetch %s: %w", pageURL, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
return nil, false, fmt.Errorf("upstream returned %d for %s", resp.StatusCode, pageURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseBrowseHTML(resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseBrowseHTML parses a novelfire HTML response body. Mirrors parseBrowsePage
|
|
||||||
// in backend/handlers.go — kept separate to avoid coupling packages.
|
|
||||||
func parseBrowseHTML(r io.Reader) ([]browseNovelListing, bool, error) {
|
|
||||||
data, err := io.ReadAll(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
body := string(data)
|
|
||||||
|
|
||||||
hasNext := strings.Contains(body, `rel="next"`) ||
|
|
||||||
strings.Contains(body, `aria-label="Next"`) ||
|
|
||||||
strings.Contains(body, `class="next"`)
|
|
||||||
|
|
||||||
slugRe := regexp.MustCompile(`href="/book/([^/"]+)"`)
|
|
||||||
titleRe := regexp.MustCompile(`class="novel-title[^"]*"[^>]*>([^<]+)<`)
|
|
||||||
coverRe := regexp.MustCompile(`data-src="(https?://[^"]+)"`)
|
|
||||||
|
|
||||||
slugMatches := slugRe.FindAllStringSubmatch(body, -1)
|
|
||||||
titleMatches := titleRe.FindAllStringSubmatch(body, -1)
|
|
||||||
coverMatches := coverRe.FindAllStringSubmatch(body, -1)
|
|
||||||
|
|
||||||
var novels []browseNovelListing
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
for i, sm := range slugMatches {
|
|
||||||
slug := sm[1]
|
|
||||||
if seen[slug] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[slug] = true
|
|
||||||
|
|
||||||
item := browseNovelListing{
|
|
||||||
Slug: slug,
|
|
||||||
URL: novelFireBrowseBase + "/book/" + slug,
|
|
||||||
}
|
|
||||||
if i < len(titleMatches) {
|
|
||||||
item.Title = strings.TrimSpace(titleMatches[i][1])
|
|
||||||
}
|
|
||||||
if i < len(coverMatches) {
|
|
||||||
item.Cover = coverMatches[i][1]
|
|
||||||
}
|
|
||||||
if item.Title != "" {
|
|
||||||
novels = append(novels, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return novels, hasNext, nil
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
// - Audio tasks fetch chapter text, call Kokoro, upload to MinIO, and report
|
// - Audio tasks fetch chapter text, call Kokoro, upload to MinIO, and report
|
||||||
// the result back (up to MaxConcurrentAudio goroutines).
|
// the result back (up to MaxConcurrentAudio goroutines).
|
||||||
// - The runner is stateless between ticks; all state lives in PocketBase.
|
// - The runner is stateless between ticks; all state lives in PocketBase.
|
||||||
|
// - Atomic task counters are exposed via /metrics (see metrics.go).
|
||||||
|
// - Books are indexed in Meilisearch via an orchestrator.Config.PostMetadata
|
||||||
|
// hook injected at construction time.
|
||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,11 +19,17 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
|
||||||
"github.com/libnovel/backend/internal/bookstore"
|
"github.com/libnovel/backend/internal/bookstore"
|
||||||
"github.com/libnovel/backend/internal/domain"
|
"github.com/libnovel/backend/internal/domain"
|
||||||
"github.com/libnovel/backend/internal/kokoro"
|
"github.com/libnovel/backend/internal/kokoro"
|
||||||
|
"github.com/libnovel/backend/internal/meili"
|
||||||
"github.com/libnovel/backend/internal/orchestrator"
|
"github.com/libnovel/backend/internal/orchestrator"
|
||||||
"github.com/libnovel/backend/internal/scraper"
|
"github.com/libnovel/backend/internal/scraper"
|
||||||
"github.com/libnovel/backend/internal/taskqueue"
|
"github.com/libnovel/backend/internal/taskqueue"
|
||||||
@@ -44,9 +53,18 @@ type Config struct {
|
|||||||
// StaleTaskThreshold is how old a heartbeat must be (or absent) before the
|
// 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.
|
// task is considered orphaned and reset to pending. Defaults to 2m when 0.
|
||||||
StaleTaskThreshold time.Duration
|
StaleTaskThreshold time.Duration
|
||||||
// BrowseRefreshInterval is how often the runner pre-fetches browse page
|
// CatalogueRefreshInterval is how often the runner walks the full catalogue,
|
||||||
// snapshots from novelfire.net and stores them in MinIO. Defaults to 6h.
|
// scrapes per-book metadata, downloads covers, and re-indexes everything in
|
||||||
BrowseRefreshInterval time.Duration
|
// Meilisearch. Defaults to 24h (expensive — full catalogue walk).
|
||||||
|
CatalogueRefreshInterval time.Duration
|
||||||
|
// SkipInitialCatalogueRefresh suppresses the immediate catalogue walk that
|
||||||
|
// otherwise fires at startup. The periodic ticker (CatalogueRefreshInterval)
|
||||||
|
// still fires normally. Set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true for
|
||||||
|
// quick restarts where the catalogue is already up to date.
|
||||||
|
SkipInitialCatalogueRefresh bool
|
||||||
|
// MetricsAddr is the HTTP listen address for the /metrics endpoint.
|
||||||
|
// Defaults to ":9091". Set to "" to disable.
|
||||||
|
MetricsAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dependencies are the external services the runner depends on.
|
// Dependencies are the external services the runner depends on.
|
||||||
@@ -59,8 +77,11 @@ type Dependencies struct {
|
|||||||
BookReader bookstore.BookReader
|
BookReader bookstore.BookReader
|
||||||
// AudioStore persists generated audio and checks key existence.
|
// AudioStore persists generated audio and checks key existence.
|
||||||
AudioStore bookstore.AudioStore
|
AudioStore bookstore.AudioStore
|
||||||
// BrowseStore stores browse page snapshots in MinIO.
|
// CoverStore stores book cover images in MinIO.
|
||||||
BrowseStore bookstore.BrowseStore
|
CoverStore bookstore.CoverStore
|
||||||
|
// SearchIndex indexes books in Meilisearch after scraping.
|
||||||
|
// If nil a no-op is used.
|
||||||
|
SearchIndex meili.Client
|
||||||
// Novel is the scraper implementation.
|
// Novel is the scraper implementation.
|
||||||
Novel scraper.NovelScraper
|
Novel scraper.NovelScraper
|
||||||
// Kokoro is the TTS client.
|
// Kokoro is the TTS client.
|
||||||
@@ -73,10 +94,16 @@ type Dependencies struct {
|
|||||||
type Runner struct {
|
type Runner struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
deps Dependencies
|
deps Dependencies
|
||||||
|
|
||||||
|
// Atomic task counters — read by /metrics without locking.
|
||||||
|
tasksRunning atomic.Int64
|
||||||
|
tasksCompleted atomic.Int64
|
||||||
|
tasksFailed atomic.Int64
|
||||||
|
|
||||||
|
startedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Runner from cfg and deps.
|
// 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 {
|
func New(cfg Config, deps Dependencies) *Runner {
|
||||||
if cfg.PollInterval <= 0 {
|
if cfg.PollInterval <= 0 {
|
||||||
cfg.PollInterval = 30 * time.Second
|
cfg.PollInterval = 30 * time.Second
|
||||||
@@ -96,63 +123,63 @@ func New(cfg Config, deps Dependencies) *Runner {
|
|||||||
if cfg.StaleTaskThreshold <= 0 {
|
if cfg.StaleTaskThreshold <= 0 {
|
||||||
cfg.StaleTaskThreshold = 2 * time.Minute
|
cfg.StaleTaskThreshold = 2 * time.Minute
|
||||||
}
|
}
|
||||||
if cfg.BrowseRefreshInterval <= 0 {
|
if cfg.CatalogueRefreshInterval <= 0 {
|
||||||
cfg.BrowseRefreshInterval = 6 * time.Hour
|
cfg.CatalogueRefreshInterval = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if cfg.MetricsAddr == "" {
|
||||||
|
cfg.MetricsAddr = ":9091"
|
||||||
}
|
}
|
||||||
if deps.Log == nil {
|
if deps.Log == nil {
|
||||||
deps.Log = slog.Default()
|
deps.Log = slog.Default()
|
||||||
}
|
}
|
||||||
return &Runner{cfg: cfg, deps: deps}
|
if deps.SearchIndex == nil {
|
||||||
}
|
deps.SearchIndex = meili.NoopClient{}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
return &Runner{cfg: cfg, deps: deps, startedAt: time.Now()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the poll loop, blocking until ctx is cancelled.
|
// Run starts the poll loop and the metrics HTTP server, blocking until ctx is
|
||||||
// On each tick it claims and executes all available pending tasks.
|
// cancelled.
|
||||||
// Scrape and audio tasks run in separate goroutine pools bounded by
|
|
||||||
// MaxConcurrentScrape and MaxConcurrentAudio respectively.
|
|
||||||
func (r *Runner) Run(ctx context.Context) error {
|
func (r *Runner) Run(ctx context.Context) error {
|
||||||
r.deps.Log.Info("runner: starting",
|
r.deps.Log.Info("runner: starting",
|
||||||
"worker_id", r.cfg.WorkerID,
|
"worker_id", r.cfg.WorkerID,
|
||||||
"poll_interval", r.cfg.PollInterval,
|
"poll_interval", r.cfg.PollInterval,
|
||||||
"max_scrape", r.cfg.MaxConcurrentScrape,
|
"max_scrape", r.cfg.MaxConcurrentScrape,
|
||||||
"max_audio", r.cfg.MaxConcurrentAudio,
|
"max_audio", r.cfg.MaxConcurrentAudio,
|
||||||
"browse_refresh_interval", r.cfg.BrowseRefreshInterval,
|
"catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval,
|
||||||
|
"metrics_addr", r.cfg.MetricsAddr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Start metrics HTTP server in background if configured.
|
||||||
|
if r.cfg.MetricsAddr != "" {
|
||||||
|
ms := newMetricsServer(r.cfg.MetricsAddr, r, r.deps.Log)
|
||||||
|
go func() {
|
||||||
|
if err := ms.ListenAndServe(ctx); err != nil {
|
||||||
|
r.deps.Log.Error("runner: metrics server error", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
|
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
|
||||||
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
|
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
|
||||||
var wg sync.WaitGroup
|
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)
|
tick := time.NewTicker(r.cfg.PollInterval)
|
||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
|
|
||||||
browseTick := time.NewTicker(r.cfg.BrowseRefreshInterval)
|
catalogueTick := time.NewTicker(r.cfg.CatalogueRefreshInterval)
|
||||||
defer browseTick.Stop()
|
defer catalogueTick.Stop()
|
||||||
|
|
||||||
// Run one browse refresh and one poll immediately on startup.
|
// Run one catalogue refresh immediately on startup (unless skipped by flag).
|
||||||
go r.runBrowseRefresh(ctx)
|
if !r.cfg.SkipInitialCatalogueRefresh {
|
||||||
|
go r.runCatalogueRefresh(ctx)
|
||||||
|
} else {
|
||||||
|
r.deps.Log.Info("runner: skipping initial catalogue refresh (RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true)")
|
||||||
|
}
|
||||||
|
|
||||||
// Run one poll immediately on startup, then on each tick.
|
// Run one poll immediately on startup, then on each tick.
|
||||||
for {
|
for {
|
||||||
r.poll(ctx, scrapeSem, audioSem, &wg)
|
r.poll(ctx, scrapeSem, audioSem, &wg)
|
||||||
r.touchAlive()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -169,16 +196,24 @@ func (r *Runner) Run(ctx context.Context) error {
|
|||||||
r.deps.Log.Warn("runner: drain timeout exceeded, forcing exit")
|
r.deps.Log.Warn("runner: drain timeout exceeded, forcing exit")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case <-browseTick.C:
|
case <-catalogueTick.C:
|
||||||
go r.runBrowseRefresh(ctx)
|
go r.runCatalogueRefresh(ctx)
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// poll claims all available pending tasks and dispatches them to goroutines.
|
// 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) {
|
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) {
|
||||||
|
// ── Heartbeat file ────────────────────────────────────────────────────
|
||||||
|
// Touch /tmp/runner.alive so the Docker health check can confirm the
|
||||||
|
// runner is actively polling. Failure is non-fatal — just log it.
|
||||||
|
if f, err := os.Create("/tmp/runner.alive"); err != nil {
|
||||||
|
r.deps.Log.Warn("runner: could not write heartbeat file", "err", err)
|
||||||
|
} else {
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reap orphaned tasks ───────────────────────────────────────────────
|
// ── Reap orphaned tasks ───────────────────────────────────────────────
|
||||||
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
|
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
|
||||||
r.deps.Log.Warn("runner: reap stale tasks failed", "err", err)
|
r.deps.Log.Warn("runner: reap stale tasks failed", "err", err)
|
||||||
@@ -197,63 +232,90 @@ func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
break // queue empty
|
break
|
||||||
}
|
}
|
||||||
// Acquire semaphore (non-blocking when full — leave task running).
|
|
||||||
select {
|
select {
|
||||||
case scrapeSem <- struct{}{}:
|
case scrapeSem <- struct{}{}:
|
||||||
default:
|
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",
|
r.deps.Log.Warn("runner: scrape semaphore full, will retry next tick",
|
||||||
"task_id", task.ID)
|
"task_id", task.ID)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
r.tasksRunning.Add(1)
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(t domain.ScrapeTask) {
|
go func(t domain.ScrapeTask) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-scrapeSem }()
|
defer func() { <-scrapeSem }()
|
||||||
|
defer r.tasksRunning.Add(-1)
|
||||||
r.runScrapeTask(ctx, t)
|
r.runScrapeTask(ctx, t)
|
||||||
}(task)
|
}(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Audio tasks ───────────────────────────────────────────────────────
|
// ── Audio tasks ───────────────────────────────────────────────────────
|
||||||
|
// Only claim tasks when there is a free slot in the semaphore.
|
||||||
|
// This avoids the old bug where we claimed (status→running) a task and
|
||||||
|
// then couldn't dispatch it, leaving it orphaned until the reaper fired.
|
||||||
|
audioLoop:
|
||||||
for {
|
for {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Check capacity before claiming to avoid orphaning tasks.
|
||||||
|
select {
|
||||||
|
case audioSem <- struct{}{}:
|
||||||
|
// Slot acquired — proceed to claim a task.
|
||||||
|
default:
|
||||||
|
// All slots busy; leave remaining pending tasks for next tick.
|
||||||
|
break audioLoop
|
||||||
|
}
|
||||||
task, ok, err := r.deps.Consumer.ClaimNextAudioTask(ctx, r.cfg.WorkerID)
|
task, ok, err := r.deps.Consumer.ClaimNextAudioTask(ctx, r.cfg.WorkerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
<-audioSem // release the pre-acquired slot
|
||||||
r.deps.Log.Error("runner: ClaimNextAudioTask failed", "err", err)
|
r.deps.Log.Error("runner: ClaimNextAudioTask failed", "err", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
break // queue empty
|
<-audioSem // release the pre-acquired slot; queue empty
|
||||||
}
|
|
||||||
select {
|
|
||||||
case audioSem <- struct{}{}:
|
|
||||||
default:
|
|
||||||
r.deps.Log.Warn("runner: audio semaphore full, will retry next tick",
|
|
||||||
"task_id", task.ID)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
r.tasksRunning.Add(1)
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(t domain.AudioTask) {
|
go func(t domain.AudioTask) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-audioSem }()
|
defer func() { <-audioSem }()
|
||||||
|
defer r.tasksRunning.Add(-1)
|
||||||
r.runAudioTask(ctx, t)
|
r.runAudioTask(ctx, t)
|
||||||
}(task)
|
}(task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.
|
||||||
|
func (r *Runner) newOrchestrator() *orchestrator.Orchestrator {
|
||||||
|
oCfg := orchestrator.Config{
|
||||||
|
Workers: r.cfg.OrchestratorWorkers,
|
||||||
|
PostMetadata: func(ctx context.Context, meta domain.BookMeta) {
|
||||||
|
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
|
||||||
|
r.deps.Log.Warn("runner: meilisearch upsert failed",
|
||||||
|
"slug", meta.Slug, "err", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log)
|
||||||
|
}
|
||||||
|
|
||||||
// runScrapeTask executes one scrape task end-to-end and reports the result.
|
// runScrapeTask executes one scrape task end-to-end and reports the result.
|
||||||
func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||||
|
ctx, span := otel.Tracer("runner").Start(ctx, "runner.scrape_task")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("task.id", task.ID),
|
||||||
|
attribute.String("task.kind", task.Kind),
|
||||||
|
attribute.String("task.url", task.TargetURL),
|
||||||
|
)
|
||||||
|
|
||||||
log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL)
|
log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL)
|
||||||
log.Info("runner: scrape task starting")
|
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)
|
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||||
defer hbCancel()
|
defer hbCancel()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -271,9 +333,7 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
oCfg := orchestrator.Config{Workers: r.cfg.OrchestratorWorkers}
|
o := r.newOrchestrator()
|
||||||
o := orchestrator.New(oCfg, r.deps.Novel, r.deps.BookWriter, r.deps.Log)
|
|
||||||
|
|
||||||
var result domain.ScrapeResult
|
var result domain.ScrapeResult
|
||||||
|
|
||||||
switch task.Kind {
|
switch task.Kind {
|
||||||
@@ -289,6 +349,15 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
|||||||
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishScrapeTask failed", "err", err)
|
log.Error("runner: FinishScrapeTask failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.ErrorMessage != "" {
|
||||||
|
r.tasksFailed.Add(1)
|
||||||
|
span.SetStatus(codes.Error, result.ErrorMessage)
|
||||||
|
} else {
|
||||||
|
r.tasksCompleted.Add(1)
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("runner: scrape task finished",
|
log.Info("runner: scrape task finished",
|
||||||
"scraped", result.ChaptersScraped,
|
"scraped", result.ChaptersScraped,
|
||||||
"skipped", result.ChaptersSkipped,
|
"skipped", result.ChaptersSkipped,
|
||||||
@@ -296,8 +365,7 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCatalogueTask runs a full catalogue scrape by iterating catalogue entries
|
// runCatalogueTask runs a full catalogue scrape.
|
||||||
// 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 {
|
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)
|
entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx)
|
||||||
var result domain.ScrapeResult
|
var result domain.ScrapeResult
|
||||||
@@ -328,17 +396,20 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAudioTask executes one audio-generation task:
|
// 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) {
|
func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||||
|
ctx, span := otel.Tracer("runner").Start(ctx, "runner.audio_task")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("task.id", task.ID),
|
||||||
|
attribute.String("book.slug", task.Slug),
|
||||||
|
attribute.Int("chapter.number", task.Chapter),
|
||||||
|
attribute.String("audio.voice", task.Voice),
|
||||||
|
)
|
||||||
|
|
||||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice)
|
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice)
|
||||||
log.Info("runner: audio task starting")
|
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)
|
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||||
defer hbCancel()
|
defer hbCancel()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -358,13 +429,14 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
|||||||
|
|
||||||
fail := func(msg string) {
|
fail := func(msg string) {
|
||||||
log.Error("runner: audio task failed", "reason", msg)
|
log.Error("runner: audio task failed", "reason", msg)
|
||||||
|
r.tasksFailed.Add(1)
|
||||||
|
span.SetStatus(codes.Error, msg)
|
||||||
result := domain.AudioResult{ErrorMessage: msg}
|
result := domain.AudioResult{ErrorMessage: msg}
|
||||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: read chapter text.
|
|
||||||
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(fmt.Sprintf("read chapter: %v", err))
|
fail(fmt.Sprintf("read chapter: %v", err))
|
||||||
@@ -376,7 +448,6 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: generate audio.
|
|
||||||
if r.deps.Kokoro == nil {
|
if r.deps.Kokoro == nil {
|
||||||
fail("kokoro client not configured")
|
fail("kokoro client not configured")
|
||||||
return
|
return
|
||||||
@@ -387,14 +458,14 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: upload to MinIO.
|
|
||||||
key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice)
|
key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice)
|
||||||
if err := r.deps.AudioStore.PutAudio(ctx, key, audioData); err != nil {
|
if err := r.deps.AudioStore.PutAudio(ctx, key, audioData); err != nil {
|
||||||
fail(fmt.Sprintf("put audio: %v", err))
|
fail(fmt.Sprintf("put audio: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: report success.
|
r.tasksCompleted.Add(1)
|
||||||
|
span.SetStatus(codes.Ok, "")
|
||||||
result := domain.AudioResult{ObjectKey: key}
|
result := domain.AudioResult{ObjectKey: key}
|
||||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (s *stubNovelScraper) ScrapeMetadata(_ context.Context, _ string) (domain.B
|
|||||||
return domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"}, nil
|
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) {
|
func (s *stubNovelScraper) ScrapeChapterList(_ context.Context, _ string, _ int) ([]domain.ChapterRef, error) {
|
||||||
return s.chapters, nil
|
return s.chapters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ type MetadataProvider interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ChapterListProvider can enumerate all chapters of a book.
|
// ChapterListProvider can enumerate all chapters of a book.
|
||||||
|
// upTo > 0 stops pagination once at least upTo chapter numbers have been
|
||||||
|
// collected (early-exit optimisation for range scrapes). upTo == 0 fetches all pages.
|
||||||
type ChapterListProvider interface {
|
type ChapterListProvider interface {
|
||||||
ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error)
|
ScrapeChapterList(ctx context.Context, bookURL string, upTo int) ([]domain.ChapterRef, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChapterTextProvider can extract the readable text from a single chapter page.
|
// ChapterTextProvider can extract the readable text from a single chapter page.
|
||||||
|
|||||||
@@ -119,10 +119,10 @@ func AvatarObjectKey(userID, ext string) string {
|
|||||||
return fmt.Sprintf("%s/%s.%s", userID, ext, ext)
|
return fmt.Sprintf("%s/%s.%s", userID, ext, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrowseObjectKey returns the MinIO object key for a cached browse page snapshot.
|
// CoverObjectKey returns the MinIO object key for a book cover image.
|
||||||
// Format: browse/{genre}/{sort}/{status}/{type}/page-{n}.json
|
// Format: covers/{slug}.jpg
|
||||||
func BrowseObjectKey(genre, sort, status, novelType string, page int) string {
|
func CoverObjectKey(slug string) string {
|
||||||
return fmt.Sprintf("browse/%s/%s/%s/%s/page-%d.json", genre, sort, status, novelType, page)
|
return fmt.Sprintf("covers/%s.jpg", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chapterNumberFromKey extracts the chapter number from a MinIO object key.
|
// chapterNumberFromKey extracts the chapter number from a MinIO object key.
|
||||||
@@ -201,16 +201,16 @@ func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string)
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Browse operations ─────────────────────────────────────────────────────────
|
// ── Cover operations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// putBrowse stores raw JSON bytes for a browse page snapshot.
|
// putCover stores a raw cover image in the browse bucket under covers/{slug}.jpg.
|
||||||
func (m *minioClient) putBrowse(ctx context.Context, key string, data []byte) error {
|
func (m *minioClient) putCover(ctx context.Context, key, contentType string, data []byte) error {
|
||||||
return m.putObject(ctx, m.bucketBrowse, key, "application/json", data)
|
return m.putObject(ctx, m.bucketBrowse, key, contentType, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBrowse retrieves a browse page snapshot. Returns (nil, false, nil) when
|
// getCover retrieves a cover image. Returns (nil, "", false, nil) when the
|
||||||
// the object does not exist.
|
// object does not exist.
|
||||||
func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool, error) {
|
func (m *minioClient) getCover(ctx context.Context, key string) ([]byte, bool, error) {
|
||||||
if !m.objectExists(ctx, m.bucketBrowse, key) {
|
if !m.objectExists(ctx, m.bucketBrowse, key) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
@@ -220,3 +220,25 @@ func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool,
|
|||||||
}
|
}
|
||||||
return data, true, nil
|
return data, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// coverExists returns true when the cover image object exists.
|
||||||
|
func (m *minioClient) coverExists(ctx context.Context, key string) bool {
|
||||||
|
return m.objectExists(ctx, m.bucketBrowse, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverContentType inspects the first bytes of data to determine if it is
|
||||||
|
// a JPEG or PNG image. Falls back to "image/jpeg".
|
||||||
|
func coverContentType(data []byte) string {
|
||||||
|
if len(data) >= 4 {
|
||||||
|
// PNG magic: 0x89 0x50 0x4E 0x47
|
||||||
|
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
// WebP: starts with "RIFF" at 0..3 and "WEBP" at 8..11
|
||||||
|
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||||
|
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|||||||
@@ -247,8 +247,9 @@ func (c *pbClient) claimRecord(ctx context.Context, collection, workerID string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
claim := map[string]any{
|
claim := map[string]any{
|
||||||
"status": string(domain.TaskStatusRunning),
|
"status": string(domain.TaskStatusRunning),
|
||||||
"worker_id": workerID,
|
"worker_id": workerID,
|
||||||
|
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
for k, v := range extraClaim {
|
for k, v := range extraClaim {
|
||||||
claim[k] = v
|
claim[k] = v
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ var _ bookstore.RankingStore = (*Store)(nil)
|
|||||||
var _ bookstore.AudioStore = (*Store)(nil)
|
var _ bookstore.AudioStore = (*Store)(nil)
|
||||||
var _ bookstore.PresignStore = (*Store)(nil)
|
var _ bookstore.PresignStore = (*Store)(nil)
|
||||||
var _ bookstore.ProgressStore = (*Store)(nil)
|
var _ bookstore.ProgressStore = (*Store)(nil)
|
||||||
var _ bookstore.BrowseStore = (*Store)(nil)
|
var _ bookstore.CoverStore = (*Store)(nil)
|
||||||
var _ taskqueue.Producer = (*Store)(nil)
|
var _ taskqueue.Producer = (*Store)(nil)
|
||||||
var _ taskqueue.Consumer = (*Store)(nil)
|
var _ taskqueue.Consumer = (*Store)(nil)
|
||||||
var _ taskqueue.Reader = (*Store)(nil)
|
var _ taskqueue.Reader = (*Store)(nil)
|
||||||
@@ -69,6 +69,7 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
|||||||
"total_chapters": meta.TotalChapters,
|
"total_chapters": meta.TotalChapters,
|
||||||
"source_url": meta.SourceURL,
|
"source_url": meta.SourceURL,
|
||||||
"ranking": meta.Ranking,
|
"ranking": meta.Ranking,
|
||||||
|
"rating": meta.Rating,
|
||||||
}
|
}
|
||||||
// Upsert via filter: if exists PATCH, otherwise POST.
|
// Upsert via filter: if exists PATCH, otherwise POST.
|
||||||
existing, err := s.getBookBySlug(ctx, meta.Slug)
|
existing, err := s.getBookBySlug(ctx, meta.Slug)
|
||||||
@@ -138,10 +139,15 @@ type pbBook struct {
|
|||||||
TotalChapters int `json:"total_chapters"`
|
TotalChapters int `json:"total_chapters"`
|
||||||
SourceURL string `json:"source_url"`
|
SourceURL string `json:"source_url"`
|
||||||
Ranking int `json:"ranking"`
|
Ranking int `json:"ranking"`
|
||||||
|
Rating float64 `json:"rating"`
|
||||||
Updated string `json:"updated"`
|
Updated string `json:"updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b pbBook) toDomain() domain.BookMeta {
|
func (b pbBook) toDomain() domain.BookMeta {
|
||||||
|
var metaUpdated int64
|
||||||
|
if t, err := time.Parse(time.RFC3339, b.Updated); err == nil {
|
||||||
|
metaUpdated = t.Unix()
|
||||||
|
}
|
||||||
return domain.BookMeta{
|
return domain.BookMeta{
|
||||||
Slug: b.Slug,
|
Slug: b.Slug,
|
||||||
Title: b.Title,
|
Title: b.Title,
|
||||||
@@ -153,6 +159,8 @@ func (b pbBook) toDomain() domain.BookMeta {
|
|||||||
TotalChapters: b.TotalChapters,
|
TotalChapters: b.TotalChapters,
|
||||||
SourceURL: b.SourceURL,
|
SourceURL: b.SourceURL,
|
||||||
Ranking: b.Ranking,
|
Ranking: b.Ranking,
|
||||||
|
Rating: b.Rating,
|
||||||
|
MetaUpdated: metaUpdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +409,17 @@ func (s *Store) PresignAvatarURL(ctx context.Context, userID string) (string, bo
|
|||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) PutAvatar(ctx context.Context, userID, ext, contentType string, data []byte) (string, error) {
|
||||||
|
// Delete existing avatar objects for this user before writing the new one
|
||||||
|
// so old extensions don't linger (e.g. old .png after uploading a .jpg).
|
||||||
|
_ = s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
|
||||||
|
key := AvatarObjectKey(userID, ext)
|
||||||
|
if err := s.mc.putObject(ctx, s.mc.bucketAvatars, key, contentType, data); err != nil {
|
||||||
|
return "", fmt.Errorf("put avatar: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteAvatar(ctx context.Context, userID string) error {
|
func (s *Store) DeleteAvatar(ctx context.Context, userID string) error {
|
||||||
return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
|
return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
|
||||||
}
|
}
|
||||||
@@ -770,21 +789,32 @@ func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BrowseStore ────────────────────────────────────────────────────────────────
|
// ── CoverStore ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *Store) PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error {
|
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
|
||||||
key := BrowseObjectKey(genre, sort, status, novelType, page)
|
key := CoverObjectKey(slug)
|
||||||
if err := s.mc.putBrowse(ctx, key, data); err != nil {
|
if contentType == "" {
|
||||||
return fmt.Errorf("PutBrowsePage: %w", err)
|
contentType = coverContentType(data)
|
||||||
|
}
|
||||||
|
if err := s.mc.putCover(ctx, key, contentType, data); err != nil {
|
||||||
|
return fmt.Errorf("PutCover: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error) {
|
func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) {
|
||||||
key := BrowseObjectKey(genre, sort, status, novelType, page)
|
key := CoverObjectKey(slug)
|
||||||
data, ok, err := s.mc.getBrowse(ctx, key)
|
data, ok, err := s.mc.getCover(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf("GetBrowsePage: %w", err)
|
return nil, "", false, fmt.Errorf("GetCover: %w", err)
|
||||||
}
|
}
|
||||||
return data, ok, nil
|
if !ok {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
ct := coverContentType(data)
|
||||||
|
return data, ct, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
|
||||||
|
return s.mc.coverExists(ctx, CoverObjectKey(slug))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM caddy:2-builder AS builder
|
FROM caddy:2-builder AS builder
|
||||||
|
|
||||||
RUN xcaddy build \
|
RUN xcaddy build \
|
||||||
--with github.com/mholt/caddy-ratelimit
|
--with github.com/mholt/caddy-ratelimit \
|
||||||
|
--with github.com/hslatman/caddy-crowdsec-bouncer/http
|
||||||
|
|
||||||
FROM caddy:2-alpine
|
FROM caddy:2-alpine
|
||||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
12
crowdsec/acquis.yaml
Normal file
12
crowdsec/acquis.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# CrowdSec log acquisition — tells the CrowdSec agent which logs to parse.
|
||||||
|
#
|
||||||
|
# Caddy writes JSON access logs to /var/log/caddy/access.log (mounted from the
|
||||||
|
# caddy_logs Docker volume). CrowdSec reads the same volume at the same path.
|
||||||
|
#
|
||||||
|
# The `crowdsecurity/caddy` collection (installed via COLLECTIONS env var)
|
||||||
|
# provides the parser that understands Caddy's JSON log format.
|
||||||
|
|
||||||
|
filenames:
|
||||||
|
- /var/log/caddy/access.log
|
||||||
|
labels:
|
||||||
|
type: caddy
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
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;
|
|
||||||
mc mb --ignore-existing local/libnovel-browse;
|
|
||||||
echo 'buckets ready';
|
|
||||||
"
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
|
||||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
|
||||||
|
|
||||||
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
|
|
||||||
pocketbase:
|
|
||||||
image: ghcr.io/muchobien/pocketbase:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
|
||||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
|
||||||
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-v2.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_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
|
||||||
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_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
|
||||||
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,18 +1,39 @@
|
|||||||
version: "3.9"
|
# ── Shared environment fragments ──────────────────────────────────────────────
|
||||||
|
# These YAML anchors eliminate duplication between backend and runner.
|
||||||
|
# All values come from Doppler — no fallbacks needed here.
|
||||||
|
# Run commands via: just up / just build / etc. (see justfile)
|
||||||
|
x-infra-env: &infra-env
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT: "minio:9000"
|
||||||
|
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
|
||||||
|
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
|
||||||
|
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
|
||||||
|
# PocketBase
|
||||||
|
POCKETBASE_URL: "http://pocketbase:8090"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
|
# Meilisearch
|
||||||
|
MEILI_URL: "http://meilisearch:7700"
|
||||||
|
MEILI_API_KEY: "${MEILI_MASTER_KEY}"
|
||||||
|
# Valkey
|
||||||
|
VALKEY_ADDR: "valkey:6379"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ─── MinIO (object storage for chapter .md files + audio cache) ─────────────
|
# ─── MinIO (object storage: chapters, audio, avatars, browse) ────────────────
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
#container_name: libnovel-minio
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
MINIO_ROOT_USER: "${MINIO_ROOT_USER}"
|
||||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}"
|
||||||
ports:
|
# No public port — all presigned URL traffic goes through backend or a
|
||||||
- "${MINIO_PORT:-9000}:9000" # S3 API
|
# separately-exposed MINIO_PUBLIC_ENDPOINT (e.g. storage.libnovel.cc).
|
||||||
- "${MINIO_CONSOLE_PORT:-9001}:9001" # Web console
|
expose:
|
||||||
|
- "9000"
|
||||||
|
- "9001"
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -22,37 +43,34 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ─── MinIO bucket initialisation ─────────────────────────────────────────────
|
# ─── MinIO bucket initialisation ─────────────────────────────────────────────
|
||||||
# Runs once to create the default buckets and then exits.
|
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc:latest
|
||||||
#container_name: libnovel-minio-init
|
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-admin} $${MINIO_ROOT_PASSWORD:-changeme123};
|
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
|
||||||
mc mb --ignore-existing local/libnovel-chapters;
|
mc mb --ignore-existing local/chapters;
|
||||||
mc mb --ignore-existing local/libnovel-audio;
|
mc mb --ignore-existing local/audio;
|
||||||
mc mb --ignore-existing local/libnovel-browse;
|
mc mb --ignore-existing local/avatars;
|
||||||
mc mb --ignore-existing local/libnovel-avatars;
|
mc mb --ignore-existing local/catalogue;
|
||||||
echo 'buckets ready';
|
echo 'buckets ready';
|
||||||
"
|
"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-admin}"
|
MINIO_ROOT_USER: "${MINIO_ROOT_USER}"
|
||||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-changeme123}"
|
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}"
|
||||||
|
|
||||||
# ─── PocketBase (auth + structured data: books, chapters index, ranking, progress) ──
|
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
|
||||||
pocketbase:
|
pocketbase:
|
||||||
image: ghcr.io/muchobien/pocketbase:latest
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
#container_name: libnovel-pocketbase
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Auto-create superuser on first boot (used by entrypoint.sh)
|
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
# No public port — accessed only by backend/runner on the internal network.
|
||||||
ports:
|
expose:
|
||||||
- "${POCKETBASE_PORT:-8090}:8090"
|
- "8090"
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb_data
|
- pb_data:/pb_data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -61,9 +79,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ─── PocketBase collection bootstrap ────────────────────────────────────────
|
# ─── 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:
|
pb-init:
|
||||||
image: alpine:3.19
|
image: alpine:3.19
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -71,22 +87,59 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
POCKETBASE_URL: "http://pocketbase:8090"
|
POCKETBASE_URL: "http://pocketbase:8090"
|
||||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./scripts/pb-init.sh:/pb-init.sh:ro
|
- ./scripts/pb-init-v3.sh:/pb-init.sh:ro
|
||||||
entrypoint: ["sh", "/pb-init.sh"]
|
entrypoint: ["sh", "/pb-init.sh"]
|
||||||
|
|
||||||
# ─── Scraper ─────────────────────────────────────────────────────────────────
|
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
|
||||||
scraper:
|
meilisearch:
|
||||||
build:
|
image: getmeili/meilisearch:latest
|
||||||
context: ./scraper
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
VERSION: "${GIT_TAG:-dev}"
|
|
||||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
|
||||||
#container_name: libnovel-scraper
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY}"
|
||||||
|
MEILI_ENV: "${MEILI_ENV}"
|
||||||
|
# No public port — backend/runner reach it via internal network.
|
||||||
|
expose:
|
||||||
|
- "7700"
|
||||||
|
volumes:
|
||||||
|
- meili_data:/meili_data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7700/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─── Valkey (presign URL cache) ───────────────────────────────────────────────
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
# No public port — backend/runner/ui reach it via internal network.
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
volumes:
|
||||||
|
- valkey_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "valkey-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─── Backend API ──────────────────────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
image: kalekber/libnovel-backend:${GIT_TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: backend
|
||||||
|
args:
|
||||||
|
VERSION: "${GIT_TAG}"
|
||||||
|
COMMIT: "${GIT_COMMIT}"
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 35s
|
||||||
depends_on:
|
depends_on:
|
||||||
pb-init:
|
pb-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -94,72 +147,264 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
meilisearch:
|
||||||
|
condition: service_healthy
|
||||||
|
valkey:
|
||||||
|
condition: service_healthy
|
||||||
|
# No public port — all traffic is routed via Caddy.
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
environment:
|
environment:
|
||||||
# 0 → defaults to NumCPU inside the container.
|
<<: *infra-env
|
||||||
SCRAPER_WORKERS: "${SCRAPER_WORKERS:-0}"
|
BACKEND_HTTP_ADDR: ":8080"
|
||||||
SCRAPER_HTTP_ADDR: ":8080"
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
LOG_LEVEL: "debug"
|
KOKORO_URL: "${KOKORO_URL}"
|
||||||
# Kokoro-FastAPI TTS endpoint.
|
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||||
KOKORO_URL: "${KOKORO_URL:-https://kokoro.kalekber.cc}"
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||||
KOKORO_VOICE: "${KOKORO_VOICE:-af_bella}"
|
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||||
# MinIO / S3 object storage
|
OTEL_SERVICE_NAME: "backend"
|
||||||
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:
|
|
||||||
- "${SCRAPER_PORT:-8080}:8080"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# ─── SvelteKit UI ────────────────────────────────────────────────────────────
|
# ─── Runner (background task worker) ─────────────────────────────────────────
|
||||||
|
# profiles: [runner] prevents accidental restart on `docker compose up -d`.
|
||||||
|
# The homelab runner (192.168.0.109) is the sole worker in production.
|
||||||
|
# To start explicitly: doppler run -- docker compose --profile runner up -d runner
|
||||||
|
runner:
|
||||||
|
profiles: [runner]
|
||||||
|
image: kalekber/libnovel-runner:${GIT_TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: runner
|
||||||
|
args:
|
||||||
|
VERSION: "${GIT_TAG}"
|
||||||
|
COMMIT: "${GIT_COMMIT}"
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 135s
|
||||||
|
depends_on:
|
||||||
|
pb-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
meilisearch:
|
||||||
|
condition: service_healthy
|
||||||
|
valkey:
|
||||||
|
condition: service_healthy
|
||||||
|
# Metrics endpoint — internal only; expose publicly via Caddy if needed.
|
||||||
|
expose:
|
||||||
|
- "9091"
|
||||||
|
environment:
|
||||||
|
<<: *infra-env
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
|
# Runner tuning
|
||||||
|
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||||
|
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||||
|
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||||
|
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
|
||||||
|
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||||
|
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||||
|
# Suppress the on-startup catalogue walk — catalogue_refresh now skips
|
||||||
|
# books already in Meilisearch, so a full walk on every restart is wasteful.
|
||||||
|
# The 24h periodic ticker (CatalogueRefreshInterval) still fires normally.
|
||||||
|
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||||
|
# Kokoro-FastAPI TTS endpoint
|
||||||
|
KOKORO_URL: "${KOKORO_URL}"
|
||||||
|
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||||
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||||
|
OTEL_SERVICE_NAME: "runner"
|
||||||
|
healthcheck:
|
||||||
|
# 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:
|
ui:
|
||||||
|
image: kalekber/libnovel-ui:${GIT_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: ./ui
|
context: ./ui
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
BUILD_VERSION: "${GIT_TAG}"
|
||||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
BUILD_COMMIT: "${GIT_COMMIT}"
|
||||||
# container_name: libnovel-ui
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 35s
|
||||||
depends_on:
|
depends_on:
|
||||||
pb-init:
|
pb-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
scraper:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
pocketbase:
|
pocketbase:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
valkey:
|
||||||
|
condition: service_healthy
|
||||||
|
# No public port — all traffic via Caddy.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
SCRAPER_API_URL: "http://scraper:8080"
|
# ORIGIN must match the public URL Caddy serves on.
|
||||||
|
# adapter-node uses this for SvelteKit's built-in CSRF origin check.
|
||||||
|
ORIGIN: "${ORIGIN}"
|
||||||
|
BACKEND_API_URL: "http://backend:8080"
|
||||||
POCKETBASE_URL: "http://pocketbase:8090"
|
POCKETBASE_URL: "http://pocketbase:8090"
|
||||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
AUTH_SECRET: "${AUTH_SECRET}"
|
||||||
ports:
|
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
||||||
- "${UI_PORT:-5252}:3000"
|
# Valkey
|
||||||
|
VALKEY_ADDR: "valkey:6379"
|
||||||
|
# Umami analytics
|
||||||
|
PUBLIC_UMAMI_WEBSITE_ID: "${PUBLIC_UMAMI_WEBSITE_ID}"
|
||||||
|
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
|
||||||
|
# GlitchTip client + server-side error tracking
|
||||||
|
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
|
||||||
|
# OpenTelemetry tracing
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||||
|
OTEL_SERVICE_NAME: "ui"
|
||||||
|
# OAuth2 providers
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||||
|
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
|
||||||
|
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# ─── CrowdSec (threat detection + IP blocking) ───────────────────────────────
|
||||||
|
# Reads Caddy JSON access logs from the shared caddy_logs volume and enforces
|
||||||
|
# decisions via the Caddy bouncer plugin.
|
||||||
|
crowdsec:
|
||||||
|
image: crowdsecurity/crowdsec:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GID: "1000"
|
||||||
|
COLLECTIONS: "crowdsecurity/caddy crowdsecurity/http-dos crowdsecurity/base-http-scenarios"
|
||||||
|
volumes:
|
||||||
|
- crowdsec_data:/var/lib/crowdsec/data
|
||||||
|
- ./crowdsec/acquis.yaml:/etc/crowdsec/acquis.yaml:ro
|
||||||
|
- caddy_logs:/var/log/caddy:ro
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cscli", "version"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ─── Dozzle agent ────────────────────────────────────────────────────────────
|
||||||
|
# Exposes prod container logs to the Dozzle instance on the homelab.
|
||||||
|
# The homelab Dozzle connects here via DOZZLE_REMOTE_AGENT.
|
||||||
|
# Port 7007 is bound to localhost only — not reachable from the internet.
|
||||||
|
dozzle-agent:
|
||||||
|
image: amir20/dozzle:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: agent
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:7007:7007"
|
||||||
|
|
||||||
|
# ─── CrowdSec bouncer registration ───────────────────────────────────────────
|
||||||
|
# One-shot: registers the Caddy bouncer with the CrowdSec LAPI and writes the
|
||||||
|
# generated API key to crowdsec/.crowdsec.env, which Caddy reads via env_file.
|
||||||
|
# Uses the Docker socket to exec cscli inside the running crowdsec container.
|
||||||
|
crowdsec-init:
|
||||||
|
image: docker:cli
|
||||||
|
depends_on:
|
||||||
|
crowdsec:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./crowdsec:/crowdsec-out
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
out=/crowdsec-out/.crowdsec.env;
|
||||||
|
existing=$$(grep -s '^CROWDSEC_API_KEY=.' \"$$out\" | cut -d= -f2-);
|
||||||
|
if [ -n \"$$existing\" ]; then
|
||||||
|
echo 'crowdsec-init: key already present, skipping registration';
|
||||||
|
exit 0;
|
||||||
|
fi;
|
||||||
|
container=$$(docker ps --filter name=crowdsec --filter status=running --format '{{.Names}}' | grep -v init | head -1);
|
||||||
|
echo \"crowdsec-init: using container $$container\";
|
||||||
|
docker exec $$container cscli bouncers delete caddy-bouncer 2>/dev/null || true;
|
||||||
|
key=$$(docker exec $$container cscli bouncers add caddy-bouncer -o raw 2>&1);
|
||||||
|
if [ -z \"$$key\" ]; then
|
||||||
|
echo 'crowdsec-init: ERROR — failed to obtain bouncer key' >&2;
|
||||||
|
exit 1;
|
||||||
|
fi;
|
||||||
|
printf 'CROWDSEC_API_KEY=%s\n' \"$$key\" > \"$$out\";
|
||||||
|
echo \"crowdsec-init: bouncer key written (key length: $${#key})\";
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Caddy (reverse proxy + automatic HTTPS) ──────────────────────────────────
|
||||||
|
# Custom build includes github.com/mholt/caddy-ratelimit and
|
||||||
|
# github.com/hslatman/caddy-crowdsec-bouncer/http.
|
||||||
|
caddy:
|
||||||
|
image: kalekber/libnovel-caddy:${GIT_TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: ./caddy
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ui:
|
||||||
|
condition: service_healthy
|
||||||
|
crowdsec-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # HTTP/3 (QUIC)
|
||||||
|
environment:
|
||||||
|
DOMAIN: "${DOMAIN}"
|
||||||
|
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL}"
|
||||||
|
env_file:
|
||||||
|
- path: ./crowdsec/.crowdsec.env
|
||||||
|
required: false
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./caddy/errors:/srv/errors:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
- caddy_logs:/var/log/caddy
|
||||||
|
|
||||||
|
# ─── Watchtower (auto-redeploy custom services on new images) ────────────────
|
||||||
|
# Only watches services labelled com.centurylinklabs.watchtower.enable=true.
|
||||||
|
# Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded.
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
command: --label-enable --interval 300 --cleanup
|
||||||
|
environment:
|
||||||
|
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
|
||||||
|
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
|
||||||
|
DOCKER_API_VERSION: "1.44"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
minio_data:
|
minio_data:
|
||||||
pb_data:
|
pb_data:
|
||||||
|
meili_data:
|
||||||
|
valkey_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
caddy_logs:
|
||||||
|
crowdsec_data:
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
direction: right
|
|
||||||
|
|
||||||
# ─── External ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
novelfire: novelfire.net {
|
|
||||||
shape: cloud
|
|
||||||
style.fill: "#f0f4ff"
|
|
||||||
}
|
|
||||||
|
|
||||||
kokoro: Kokoro-FastAPI TTS {
|
|
||||||
shape: cloud
|
|
||||||
style.fill: "#f0f4ff"
|
|
||||||
}
|
|
||||||
|
|
||||||
browser: Browser / iOS App {
|
|
||||||
shape: person
|
|
||||||
style.fill: "#fff9e6"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── Init containers (one-shot) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
init: Init containers {
|
|
||||||
style.fill: "#f5f5f5"
|
|
||||||
style.stroke-dash: 4
|
|
||||||
|
|
||||||
minio-init: minio-init {
|
|
||||||
shape: rectangle
|
|
||||||
label: "minio-init\n(mc: create buckets)"
|
|
||||||
}
|
|
||||||
|
|
||||||
pb-init: pb-init {
|
|
||||||
shape: rectangle
|
|
||||||
label: "pb-init\n(bootstrap collections)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── Storage ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
storage: Storage {
|
|
||||||
style.fill: "#eaf7ea"
|
|
||||||
|
|
||||||
minio: MinIO {
|
|
||||||
shape: cylinder
|
|
||||||
label: "MinIO :9000\n\nbuckets:\n libnovel-chapters\n libnovel-audio\n libnovel-avatars\n libnovel-browse"
|
|
||||||
}
|
|
||||||
|
|
||||||
pocketbase: PocketBase {
|
|
||||||
shape: cylinder
|
|
||||||
label: "PocketBase :8090\n\ncollections:\n books chapters_idx\n audio_cache progress\n scrape_jobs app_users\n ranking"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── Application ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app: Application {
|
|
||||||
style.fill: "#eef3ff"
|
|
||||||
|
|
||||||
backend: backend {
|
|
||||||
shape: rectangle
|
|
||||||
label: "Backend API :8080\n(Go — HTTP API server)"
|
|
||||||
}
|
|
||||||
|
|
||||||
runner: runner {
|
|
||||||
shape: rectangle
|
|
||||||
label: "Runner\n(Go — background worker\nscraping + TTS jobs)"
|
|
||||||
}
|
|
||||||
|
|
||||||
ui: ui {
|
|
||||||
shape: rectangle
|
|
||||||
label: "SvelteKit UI :5252\n(adapter-node)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── Init → Storage deps ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4}
|
|
||||||
init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4}
|
|
||||||
|
|
||||||
# ─── App → Storage ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.backend -> storage.minio: blobs (chapters, audio,\navatars, browse)
|
|
||||||
app.backend -> storage.pocketbase: structured records\n(books, progress, jobs…)
|
|
||||||
|
|
||||||
app.runner -> storage.minio: write chapter markdown\n& audio MP3s
|
|
||||||
app.runner -> storage.pocketbase: read/update scrape jobs\nwrite book records
|
|
||||||
|
|
||||||
# ─── App internal ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.ui -> app.backend: REST API calls\n(server-side)
|
|
||||||
|
|
||||||
# ─── External → App ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.runner -> novelfire: scrape\n(HTTP GET)
|
|
||||||
app.runner -> kokoro: TTS generation\n(HTTP POST)
|
|
||||||
|
|
||||||
# ─── Browser ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
browser -> app.ui: HTTPS :5252
|
|
||||||
browser -> storage.minio: presigned URLs\n(audio / chapter downloads)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
%% ── External ──────────────────────────────────────────────────────────
|
|
||||||
NF([novelfire.net])
|
|
||||||
KK([Kokoro-FastAPI TTS])
|
|
||||||
CL([Browser / iOS App])
|
|
||||||
|
|
||||||
%% ── Init containers ───────────────────────────────────────────────────
|
|
||||||
subgraph INIT["Init containers (one-shot)"]
|
|
||||||
MI[minio-init\nmc: create buckets]
|
|
||||||
PI[pb-init\nbootstrap collections]
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ── Storage ───────────────────────────────────────────────────────────
|
|
||||||
subgraph STORAGE["Storage"]
|
|
||||||
MN[(MinIO :9000\nchapters · audio\navatars · browse)]
|
|
||||||
PB[(PocketBase :8090\nbooks · chapters_idx\naudio_cache · progress\nscrape_jobs · app_users · ranking)]
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ── Application ───────────────────────────────────────────────────────
|
|
||||||
subgraph APP["Application"]
|
|
||||||
BE[Backend API :8080\nGo HTTP server]
|
|
||||||
RN[Runner\nGo background worker]
|
|
||||||
UI[SvelteKit UI :5252]
|
|
||||||
end
|
|
||||||
|
|
||||||
%% ── Init → Storage ────────────────────────────────────────────────────
|
|
||||||
MI -.->|create buckets| MN
|
|
||||||
PI -.->|bootstrap schema| PB
|
|
||||||
|
|
||||||
%% ── App → Storage ─────────────────────────────────────────────────────
|
|
||||||
BE -->|blobs| MN
|
|
||||||
BE -->|structured records| PB
|
|
||||||
RN -->|chapter markdown & audio| MN
|
|
||||||
RN -->|read/update jobs & books| PB
|
|
||||||
|
|
||||||
%% ── App internal ──────────────────────────────────────────────────────
|
|
||||||
UI -->|REST API| BE
|
|
||||||
|
|
||||||
%% ── Runner → External ─────────────────────────────────────────────────
|
|
||||||
RN -->|scrape HTTP GET| NF
|
|
||||||
RN -->|TTS HTTP POST| KK
|
|
||||||
|
|
||||||
%% ── Client ────────────────────────────────────────────────────────────
|
|
||||||
CL -->|HTTPS :5252| UI
|
|
||||||
CL -->|presigned URLs| MN
|
|
||||||
```
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 43 KiB |
@@ -171,7 +171,7 @@ client -> caddy: HTTPS :443
|
|||||||
|
|
||||||
caddy -> sk: "/* (catch-all)\n→ SvelteKit handles auth"
|
caddy -> sk: "/* (catch-all)\n→ SvelteKit handles auth"
|
||||||
caddy -> be: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/catalogue /api/ranking"
|
caddy -> be: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/catalogue /api/ranking"
|
||||||
caddy -> storage.mn: "/avatars/*\n(presigned GETs)"
|
caddy -> storage.mn: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
|
||||||
|
|
||||||
# ─── SvelteKit → Backend (server-side proxy) ──────────────────────────────────
|
# ─── SvelteKit → Backend (server-side proxy) ──────────────────────────────────
|
||||||
|
|
||||||
127
docs/d2/api-routing.svg
Normal file
127
docs/d2/api-routing.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 57 KiB |
@@ -46,7 +46,7 @@ storage: Storage {
|
|||||||
|
|
||||||
minio: MinIO {
|
minio: MinIO {
|
||||||
shape: cylinder
|
shape: cylinder
|
||||||
label: "MinIO :9000\n\nbuckets:\n libnovel-chapters\n libnovel-audio\n avatars\n libnovel-browse"
|
label: "MinIO :9000\n\nbuckets:\n chapters\n audio\n avatars\n catalogue"
|
||||||
}
|
}
|
||||||
|
|
||||||
pocketbase: PocketBase {
|
pocketbase: PocketBase {
|
||||||
@@ -135,7 +135,7 @@ app.ui -> app.backend: REST API calls (server-side)\n/api/catalogue /api/book-pr
|
|||||||
|
|
||||||
app.caddy -> app.ui: "/* (catch-all)\n/api/scrape/*\n/api/chapter-text-preview/*\n→ SvelteKit (auth enforced)"
|
app.caddy -> app.ui: "/* (catch-all)\n/api/scrape/*\n/api/chapter-text-preview/*\n→ SvelteKit (auth enforced)"
|
||||||
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/*"
|
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/*"
|
||||||
app.caddy -> storage.minio: "/avatars/*\n(presigned avatar GETs)"
|
app.caddy -> storage.minio: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
|
||||||
|
|
||||||
# ─── External → App ───────────────────────────────────────────────────────────
|
# ─── External → App ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
129
docs/d2/architecture.svg
Normal file
129
docs/d2/architecture.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 58 KiB |
@@ -22,7 +22,7 @@ flowchart TD
|
|||||||
H --> I[Worker pool — N goroutines\nRUNNER_MAX_CONCURRENT_SCRAPE]
|
H --> I[Worker pool — N goroutines\nRUNNER_MAX_CONCURRENT_SCRAPE]
|
||||||
I --> J[For each missing chapter:\nGET chapter HTML from novelfire.net]
|
I --> J[For each missing chapter:\nGET chapter HTML from novelfire.net]
|
||||||
J --> K[Parse HTML → Markdown\nhtmlutil.NodeToMarkdown]
|
J --> K[Parse HTML → Markdown\nhtmlutil.NodeToMarkdown]
|
||||||
K --> L[PUT object to MinIO\nlibnovel-chapters/{slug}/{n}.md]
|
K --> L[PUT object to MinIO\nchapters/{slug}/{n}.md]
|
||||||
L --> M[Upsert book doc\nto Meilisearch index: books]
|
L --> M[Upsert book doc\nto Meilisearch index: books]
|
||||||
M --> Z
|
M --> Z
|
||||||
F --> M
|
F --> M
|
||||||
@@ -87,7 +87,7 @@ flowchart TD
|
|||||||
H --> I["GET /api/chapter-text/{slug}/{n}\nfrom backend — plain text"]
|
H --> I["GET /api/chapter-text/{slug}/{n}\nfrom backend — plain text"]
|
||||||
I --> J[POST /v1/audio/speech\nto Kokoro-FastAPI\nbody: text + voice]
|
I --> J[POST /v1/audio/speech\nto Kokoro-FastAPI\nbody: text + voice]
|
||||||
J --> K[Stream MP3 response]
|
J --> K[Stream MP3 response]
|
||||||
K --> L[PUT object to MinIO\nlibnovel-audio/{slug}/{n}/{voice}.mp3]
|
K --> L[PUT object to MinIO\naudio/{slug}/{n}/{voice}.mp3]
|
||||||
L --> M[UPDATE audio_task\nstatus=done]
|
L --> M[UPDATE audio_task\nstatus=done]
|
||||||
|
|
||||||
N(["Client polls\nGET /api/audio/status/{slug}/{n}"]) --> O{status?}
|
N(["Client polls\nGET /api/audio/status/{slug}/{n}"]) --> O{status?}
|
||||||
453
homelab/docker-compose.yml
Normal file
453
homelab/docker-compose.yml
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
# LibNovel homelab
|
||||||
|
#
|
||||||
|
# Runs on 192.168.0.109. Hosts:
|
||||||
|
# - libnovel runner (background task worker)
|
||||||
|
# - tooling: GlitchTip, Umami, Fider, Dozzle, Uptime Kuma, Gotify
|
||||||
|
# - observability: OTel Collector, Tempo, Loki, Prometheus, Grafana
|
||||||
|
# - cloudflared tunnel (public subdomains via Cloudflare Zero Trust)
|
||||||
|
# - shared Postgres for tooling DBs
|
||||||
|
#
|
||||||
|
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
|
||||||
|
# Run with: doppler run -- docker compose up -d
|
||||||
|
#
|
||||||
|
# Public subdomains (via Cloudflare Tunnel — no ports exposed to internet):
|
||||||
|
# errors.libnovel.cc → glitchtip-web:8000
|
||||||
|
# analytics.libnovel.cc → umami:3000
|
||||||
|
# feedback.libnovel.cc → fider:3000
|
||||||
|
# logs.libnovel.cc → dozzle:8080
|
||||||
|
# uptime.libnovel.cc → uptime-kuma:3001
|
||||||
|
# push.libnovel.cc → gotify:80
|
||||||
|
# grafana.libnovel.cc → grafana:3000
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── Cloudflare Tunnel ───────────────────────────────────────────────────────
|
||||||
|
# Outbound-only encrypted tunnel to Cloudflare.
|
||||||
|
# Routes all public subdomains to their respective containers on this network.
|
||||||
|
# No inbound ports needed — cloudflared initiates all connections outward.
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
|
||||||
|
environment:
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN}"
|
||||||
|
|
||||||
|
# ── LibNovel Runner ─────────────────────────────────────────────────────────
|
||||||
|
# Background task worker. Connects to prod PocketBase, MinIO, Meilisearch
|
||||||
|
# via their public subdomains (pb.libnovel.cc, storage.libnovel.cc, etc.)
|
||||||
|
runner:
|
||||||
|
image: kalekber/libnovel-runner:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 135s
|
||||||
|
labels:
|
||||||
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
environment:
|
||||||
|
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
|
|
||||||
|
MINIO_ENDPOINT: "storage.libnovel.cc"
|
||||||
|
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
|
||||||
|
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
|
||||||
|
MINIO_USE_SSL: "true"
|
||||||
|
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
|
||||||
|
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
|
||||||
|
|
||||||
|
MEILI_URL: "${MEILI_URL}"
|
||||||
|
MEILI_API_KEY: "${MEILI_API_KEY}"
|
||||||
|
VALKEY_ADDR: ""
|
||||||
|
GODEBUG: "preferIPv4=1"
|
||||||
|
|
||||||
|
KOKORO_URL: "http://kokoro-fastapi:8880"
|
||||||
|
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||||
|
|
||||||
|
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
|
||||||
|
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||||
|
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||||
|
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||||
|
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||||
|
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||||
|
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||||
|
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||||
|
|
||||||
|
# OTel — send runner traces/metrics to the local collector (HTTP)
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
|
||||||
|
OTEL_SERVICE_NAME: "runner"
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ── Shared Postgres ─────────────────────────────────────────────────────────
|
||||||
|
# Hosts glitchtip, umami, and fider databases.
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: "${POSTGRES_USER}"
|
||||||
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Postgres database initialisation ────────────────────────────────────────
|
||||||
|
postgres-init:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: "${POSTGRES_PASSWORD}"
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='fider'\" | grep -q 1 ||
|
||||||
|
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE fider\";
|
||||||
|
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='glitchtip'\" | grep -q 1 ||
|
||||||
|
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE glitchtip\";
|
||||||
|
psql -h postgres -U ${POSTGRES_USER} -d postgres -tc \"SELECT 1 FROM pg_database WHERE datname='umami'\" | grep -q 1 ||
|
||||||
|
psql -h postgres -U ${POSTGRES_USER} -d postgres -c \"CREATE DATABASE umami\";
|
||||||
|
echo 'postgres-init: databases ready';
|
||||||
|
"
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ── GlitchTip DB migration ──────────────────────────────────────────────────
|
||||||
|
glitchtip-migrate:
|
||||||
|
image: glitchtip/glitchtip:latest
|
||||||
|
depends_on:
|
||||||
|
postgres-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||||
|
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||||
|
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||||
|
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||||
|
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||||
|
VALKEY_URL: "redis://valkey:6379/1"
|
||||||
|
command: "./manage.py migrate"
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# ── GlitchTip web ───────────────────────────────────────────────────────────
|
||||||
|
glitchtip-web:
|
||||||
|
image: glitchtip/glitchtip:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
glitchtip-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||||
|
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||||
|
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||||
|
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||||
|
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||||
|
VALKEY_URL: "redis://valkey:6379/1"
|
||||||
|
PORT: "8000"
|
||||||
|
ENABLE_USER_REGISTRATION: "false"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── GlitchTip worker ────────────────────────────────────────────────────────
|
||||||
|
glitchtip-worker:
|
||||||
|
image: glitchtip/glitchtip:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
glitchtip-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/glitchtip"
|
||||||
|
SECRET_KEY: "${GLITCHTIP_SECRET_KEY}"
|
||||||
|
GLITCHTIP_DOMAIN: "${GLITCHTIP_DOMAIN}"
|
||||||
|
EMAIL_URL: "${GLITCHTIP_EMAIL_URL}"
|
||||||
|
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||||
|
VALKEY_URL: "redis://valkey:6379/1"
|
||||||
|
SERVER_ROLE: "worker"
|
||||||
|
|
||||||
|
# ── Umami ───────────────────────────────────────────────────────────────────
|
||||||
|
umami:
|
||||||
|
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/umami"
|
||||||
|
APP_SECRET: "${UMAMI_APP_SECRET}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:3000/api/heartbeat"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Fider ───────────────────────────────────────────────────────────────────
|
||||||
|
fider:
|
||||||
|
image: getfider/fider:stable
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
BASE_URL: "${FIDER_BASE_URL}"
|
||||||
|
DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/fider?sslmode=disable"
|
||||||
|
JWT_SECRET: "${FIDER_JWT_SECRET}"
|
||||||
|
EMAIL_NOREPLY: "noreply@libnovel.cc"
|
||||||
|
EMAIL_SMTP_HOST: "${FIDER_SMTP_HOST}"
|
||||||
|
EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}"
|
||||||
|
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
|
||||||
|
EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
|
||||||
|
EMAIL_SMTP_ENABLE_STARTTLS: "false"
|
||||||
|
|
||||||
|
# ── Dozzle ──────────────────────────────────────────────────────────────────
|
||||||
|
# Watches both homelab and prod containers.
|
||||||
|
# Prod agent runs on 165.22.70.138:7007 (added separately to prod compose).
|
||||||
|
dozzle:
|
||||||
|
image: amir20/dozzle:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./dozzle/users.yml:/data/users.yml:ro
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
environment:
|
||||||
|
DOZZLE_AUTH_PROVIDER: simple
|
||||||
|
DOZZLE_HOSTNAME: "logs.libnovel.cc"
|
||||||
|
DOZZLE_REMOTE_AGENT: "prod@165.22.70.138:7007"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/dozzle", "healthcheck"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Uptime Kuma ─────────────────────────────────────────────────────────────
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- uptime_kuma_data:/app/data
|
||||||
|
expose:
|
||||||
|
- "3001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "extra/healthcheck"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Gotify ──────────────────────────────────────────────────────────────────
|
||||||
|
gotify:
|
||||||
|
image: gotify/server:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- gotify_data:/app/data
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
environment:
|
||||||
|
GOTIFY_DEFAULTUSER_NAME: "${GOTIFY_ADMIN_USER}"
|
||||||
|
GOTIFY_DEFAULTUSER_PASS: "${GOTIFY_ADMIN_PASS}"
|
||||||
|
GOTIFY_SERVER_PORT: "80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Valkey ──────────────────────────────────────────────────────────────────
|
||||||
|
# Used by GlitchTip for task queuing.
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
volumes:
|
||||||
|
- valkey_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "valkey-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── OTel Collector ──────────────────────────────────────────────────────────
|
||||||
|
# Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||||
|
expose:
|
||||||
|
- "4317" # OTLP gRPC
|
||||||
|
- "4318" # OTLP HTTP
|
||||||
|
- "8888" # Collector self-metrics (scraped by Prometheus)
|
||||||
|
depends_on:
|
||||||
|
- tempo
|
||||||
|
- prometheus
|
||||||
|
- loki
|
||||||
|
# No healthcheck — distroless image has no shell or curl
|
||||||
|
|
||||||
|
# ── Tempo ───────────────────────────────────────────────────────────────────
|
||||||
|
# Distributed trace storage. Receives OTLP from the collector.
|
||||||
|
tempo:
|
||||||
|
image: grafana/tempo:2.6.1
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["-config.file=/etc/tempo.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./otel/tempo.yaml:/etc/tempo.yaml:ro
|
||||||
|
- tempo_data:/var/tempo
|
||||||
|
expose:
|
||||||
|
- "3200" # Tempo query API (queried by Grafana)
|
||||||
|
- "4317" # OTLP gRPC ingest (collector → tempo)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3200/ready"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Prometheus ──────────────────────────────────────────────────────────────
|
||||||
|
# Scrapes metrics from backend (via prod), runner, and otel-collector.
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "--config.file=/etc/prometheus/prometheus.yaml"
|
||||||
|
- "--storage.tsdb.path=/prometheus"
|
||||||
|
- "--storage.tsdb.retention.time=30d"
|
||||||
|
- "--web.enable-remote-write-receiver"
|
||||||
|
volumes:
|
||||||
|
- ./otel/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
expose:
|
||||||
|
- "9090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Loki ────────────────────────────────────────────────────────────────────
|
||||||
|
# Log aggregation. Receives logs from OTel collector. Replaces manual Dozzle
|
||||||
|
# tailing for structured log search.
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["-config.file=/etc/loki/loki.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./otel/loki.yaml:/etc/loki/loki.yaml:ro
|
||||||
|
- loki_data:/loki
|
||||||
|
expose:
|
||||||
|
- "3100"
|
||||||
|
# No healthcheck — distroless image has no shell or curl
|
||||||
|
|
||||||
|
# ── Grafana ─────────────────────────────────────────────────────────────────
|
||||||
|
# Single UI for traces (Tempo), metrics (Prometheus), and logs (Loki).
|
||||||
|
# Accessible at grafana.libnovel.cc via Cloudflare Tunnel.
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- tempo
|
||||||
|
- prometheus
|
||||||
|
- loki
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./otel/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
environment:
|
||||||
|
GF_SERVER_ROOT_URL: "https://grafana.libnovel.cc"
|
||||||
|
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
GF_FEATURE_TOGGLES_ENABLE: "traceqlEditor"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# ── Kokoro-FastAPI (GPU TTS) ────────────────────────────────────────────────
|
||||||
|
# OpenAI-compatible TTS service backed by the Kokoro model, running on the
|
||||||
|
# homelab RTX 3050 (8 GB VRAM). Replaces the broken kokoro.kalekber.cc DNS.
|
||||||
|
# Voices match existing IDs: af_bella, af_sky, af_heart, etc.
|
||||||
|
# The runner reaches it at http://kokoro-fastapi:8880 via the Docker network.
|
||||||
|
kokoro-fastapi:
|
||||||
|
image: ghcr.io/remsky/kokoro-fastapi-gpu:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
expose:
|
||||||
|
- "8880"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8880/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# ── pocket-tts (CPU TTS) ────────────────────────────────────────────────────
|
||||||
|
# Lightweight CPU-only TTS using kyutai-labs/pocket-tts.
|
||||||
|
# OpenAI-compatible: POST /v1/audio/speech on port 8000.
|
||||||
|
# Voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma.
|
||||||
|
# Not currently used by the runner (runner uses kokoro-fastapi), but available
|
||||||
|
# for experimentation / fallback.
|
||||||
|
pocket-tts:
|
||||||
|
image: ghcr.io/kyutai-labs/pocket-tts:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# ── Watchtower ──────────────────────────────────────────────────────────────
|
||||||
|
# Auto-updates runner image when CI pushes a new tag.
|
||||||
|
# Only watches services with the watchtower label.
|
||||||
|
watchtower:
|
||||||
|
image: containrrr/watchtower:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
command: --label-enable --interval 300 --cleanup
|
||||||
|
environment:
|
||||||
|
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
|
||||||
|
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
|
||||||
|
DOCKER_API_VERSION: "1.44"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
valkey_data:
|
||||||
|
uptime_kuma_data:
|
||||||
|
gotify_data:
|
||||||
|
tempo_data:
|
||||||
|
prometheus_data:
|
||||||
|
loki_data:
|
||||||
|
grafana_data:
|
||||||
5
homelab/dozzle/users.yml
Normal file
5
homelab/dozzle/users.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
users:
|
||||||
|
admin:
|
||||||
|
name: admin
|
||||||
|
email: admin@libnovel.cc
|
||||||
|
password: "$2y$10$4jqLza2grpxnQn0EGux2C.UmlSxRmOvH/J1ySzOBxMZgW6cA2TnmK"
|
||||||
68
homelab/otel/collector.yaml
Normal file
68
homelab/otel/collector.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# OTel Collector config
|
||||||
|
#
|
||||||
|
# Receivers: OTLP (gRPC + HTTP) from backend, ui, runner
|
||||||
|
# Processors: batch for efficiency, resource detection for host metadata
|
||||||
|
# Exporters: Tempo (traces), Prometheus (metrics), Loki (logs)
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
processors:
|
||||||
|
batch:
|
||||||
|
timeout: 5s
|
||||||
|
send_batch_size: 512
|
||||||
|
|
||||||
|
# Attach host metadata to all telemetry
|
||||||
|
resourcedetection:
|
||||||
|
detectors: [env, system]
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
# Traces → Tempo
|
||||||
|
otlp/tempo:
|
||||||
|
endpoint: tempo:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
# Metrics → Prometheus (remote write)
|
||||||
|
prometheusremotewrite:
|
||||||
|
endpoint: "http://prometheus:9090/api/v1/write"
|
||||||
|
tls:
|
||||||
|
insecure_skip_verify: true
|
||||||
|
|
||||||
|
# Logs → Loki (via OTLP HTTP endpoint)
|
||||||
|
otlphttp/loki:
|
||||||
|
endpoint: "http://loki:3100/otlp"
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
# Collector self-observability (optional debug)
|
||||||
|
debug:
|
||||||
|
verbosity: basic
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
health_check:
|
||||||
|
endpoint: 0.0.0.0:13133
|
||||||
|
pprof:
|
||||||
|
endpoint: 0.0.0.0:1777
|
||||||
|
|
||||||
|
service:
|
||||||
|
extensions: [health_check, pprof]
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [resourcedetection, batch]
|
||||||
|
exporters: [otlp/tempo]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [resourcedetection, batch]
|
||||||
|
exporters: [prometheusremotewrite]
|
||||||
|
logs:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [resourcedetection, batch]
|
||||||
|
exporters: [otlphttp/loki]
|
||||||
13
homelab/otel/grafana/provisioning/dashboards/dashboards.yaml
Normal file
13
homelab/otel/grafana/provisioning/dashboards/dashboards.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Grafana dashboard provisioning
|
||||||
|
# Points Grafana at the local dashboards directory.
|
||||||
|
# Drop any .json dashboard file into homelab/otel/grafana/provisioning/dashboards/
|
||||||
|
# and it will appear in Grafana automatically on restart.
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: libnovel
|
||||||
|
folder: LibNovel
|
||||||
|
type: file
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Grafana datasource provisioning
|
||||||
|
# Auto-configures Tempo, Prometheus, and Loki on first start.
|
||||||
|
# No manual setup needed in the UI.
|
||||||
|
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Tempo
|
||||||
|
type: tempo
|
||||||
|
uid: tempo
|
||||||
|
url: http://tempo:3200
|
||||||
|
access: proxy
|
||||||
|
isDefault: false
|
||||||
|
jsonData:
|
||||||
|
httpMethod: GET
|
||||||
|
serviceMap:
|
||||||
|
datasourceUid: prometheus
|
||||||
|
nodeGraph:
|
||||||
|
enabled: true
|
||||||
|
traceQuery:
|
||||||
|
timeShiftEnabled: true
|
||||||
|
spanStartTimeShift: "1h"
|
||||||
|
spanEndTimeShift: "-1h"
|
||||||
|
spanBar:
|
||||||
|
type: "Tag"
|
||||||
|
tag: "http.url"
|
||||||
|
lokiSearch:
|
||||||
|
datasourceUid: loki
|
||||||
|
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
url: http://prometheus:9090
|
||||||
|
access: proxy
|
||||||
|
isDefault: true
|
||||||
|
jsonData:
|
||||||
|
httpMethod: POST
|
||||||
|
exemplarTraceIdDestinations:
|
||||||
|
- name: traceID
|
||||||
|
datasourceUid: tempo
|
||||||
|
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
uid: loki
|
||||||
|
url: http://loki:3100
|
||||||
|
access: proxy
|
||||||
|
isDefault: false
|
||||||
|
jsonData:
|
||||||
|
derivedFields:
|
||||||
|
- datasourceUid: tempo
|
||||||
|
matcherRegex: '"traceID":"(\w+)"'
|
||||||
|
name: TraceID
|
||||||
|
url: "$${__value.raw}"
|
||||||
38
homelab/otel/loki.yaml
Normal file
38
homelab/otel/loki.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Loki config — minimal single-node setup
|
||||||
|
# Receives logs from OTel Collector. 30-day retention.
|
||||||
|
|
||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
grpc_listen_port: 9096
|
||||||
|
|
||||||
|
common:
|
||||||
|
instance_addr: 127.0.0.1
|
||||||
|
path_prefix: /loki
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
chunks_directory: /loki/chunks
|
||||||
|
rules_directory: /loki/rules
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2024-01-01
|
||||||
|
store: tsdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v13
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
retention_period: 720h # 30 days
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
working_directory: /loki/compactor
|
||||||
|
delete_request_store: filesystem
|
||||||
|
retention_enabled: true
|
||||||
22
homelab/otel/prometheus.yaml
Normal file
22
homelab/otel/prometheus.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Prometheus config
|
||||||
|
# Scrapes OTel collector self-metrics and runner metrics endpoint.
|
||||||
|
# Backend metrics come in via OTel remote-write — no direct scrape needed.
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
# OTel Collector self-metrics
|
||||||
|
- job_name: otel-collector
|
||||||
|
static_configs:
|
||||||
|
- targets: ["otel-collector:8888"]
|
||||||
|
|
||||||
|
# Runner JSON metrics endpoint (native format, no Prometheus client yet)
|
||||||
|
# Will be replaced by OTLP once runner is instrumented with OTel SDK.
|
||||||
|
- job_name: libnovel-runner
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["runner:9091"]
|
||||||
45
homelab/otel/tempo.yaml
Normal file
45
homelab/otel/tempo.yaml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Tempo config — minimal single-node setup
|
||||||
|
# Stores traces locally. Grafana queries via the HTTP API on port 3200.
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3200
|
||||||
|
|
||||||
|
distributor:
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
ingester:
|
||||||
|
trace_idle_period: 10s
|
||||||
|
max_block_bytes: 104857600 # 100MB
|
||||||
|
max_block_duration: 30m
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
compaction:
|
||||||
|
block_retention: 720h # 30 days
|
||||||
|
|
||||||
|
storage:
|
||||||
|
trace:
|
||||||
|
backend: local
|
||||||
|
local:
|
||||||
|
path: /var/tempo/blocks
|
||||||
|
wal:
|
||||||
|
path: /var/tempo/wal
|
||||||
|
|
||||||
|
metrics_generator:
|
||||||
|
registry:
|
||||||
|
external_labels:
|
||||||
|
source: tempo
|
||||||
|
storage:
|
||||||
|
path: /var/tempo/generator/wal
|
||||||
|
remote_write:
|
||||||
|
- url: http://prometheus:9090/api/v1/write
|
||||||
|
send_exemplars: true
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
defaults:
|
||||||
|
metrics_generator:
|
||||||
|
processors: [service-graphs, span-metrics]
|
||||||
|
generate_native_histograms: both
|
||||||
62
homelab/runner/docker-compose.yml
Normal file
62
homelab/runner/docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# LibNovel homelab runner
|
||||||
|
#
|
||||||
|
# Connects to production PocketBase and MinIO via public subdomains.
|
||||||
|
# All secrets come from Doppler (project=libnovel, config=prd).
|
||||||
|
# Run with: doppler run -- docker compose up -d
|
||||||
|
#
|
||||||
|
# Differs from prod runner:
|
||||||
|
# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts)
|
||||||
|
# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS
|
||||||
|
# - POCKETBASE_URL → https://pb.libnovel.cc
|
||||||
|
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
|
||||||
|
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||||
|
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||||
|
|
||||||
|
services:
|
||||||
|
runner:
|
||||||
|
image: kalekber/libnovel-runner:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 135s
|
||||||
|
environment:
|
||||||
|
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||||
|
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||||
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||||
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||||
|
|
||||||
|
# ── MinIO (S3 API via public subdomain) ─────────────────────────────────
|
||||||
|
MINIO_ENDPOINT: "storage.libnovel.cc"
|
||||||
|
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
|
||||||
|
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
|
||||||
|
MINIO_USE_SSL: "true"
|
||||||
|
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
|
||||||
|
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
|
||||||
|
|
||||||
|
# ── Meilisearch (via search.libnovel.cc Caddy proxy) ────────────────────
|
||||||
|
MEILI_URL: "${MEILI_URL}"
|
||||||
|
MEILI_API_KEY: "${MEILI_API_KEY}"
|
||||||
|
VALKEY_ADDR: ""
|
||||||
|
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
|
||||||
|
GODEBUG: "preferIPv4=1"
|
||||||
|
|
||||||
|
# ── Kokoro TTS ──────────────────────────────────────────────────────────
|
||||||
|
KOKORO_URL: "${KOKORO_URL}"
|
||||||
|
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||||
|
|
||||||
|
# ── Runner tuning ───────────────────────────────────────────────────────
|
||||||
|
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
|
||||||
|
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||||
|
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||||
|
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||||
|
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||||
|
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||||
|
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||||
|
|
||||||
|
# ── Observability ───────────────────────────────────────────────────────
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL}"
|
||||||
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
10
ios/LibNovel/.gitignore
vendored
10
ios/LibNovel/.gitignore
vendored
@@ -1,10 +0,0 @@
|
|||||||
# Fastlane
|
|
||||||
fastlane/report.xml
|
|
||||||
fastlane/Preview.html
|
|
||||||
fastlane/screenshots/**/*.png
|
|
||||||
fastlane/test_output
|
|
||||||
fastlane/README.md
|
|
||||||
|
|
||||||
# Bundler
|
|
||||||
.bundle
|
|
||||||
vendor/bundle
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
gem "fastlane"
|
|
||||||
@@ -1,772 +0,0 @@
|
|||||||
// !$*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 */;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "kingfisher",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/onevcat/Kingfisher",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
|
||||||
"version" : "8.8.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 3
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@EnvironmentObject var authStore: AuthStore
|
|
||||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if authStore.isAuthenticated {
|
|
||||||
RootTabView()
|
|
||||||
} else {
|
|
||||||
AuthView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
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
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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 }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "icon-1024.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"info": { "author": "xcode", "version": 1 }
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user