All checks were successful
Release / Scraper / Test (push) Successful in 20s
Release / UI / Build (push) Successful in 25s
CI / Scraper / Lint (pull_request) Successful in 10s
Release / v2 / Build ui-v2 (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 25s
Release / Scraper / Docker (push) Successful in 55s
Release / UI / Docker (push) Successful in 43s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 36s
Release / v2 / Test backend (push) Successful in 3m51s
Release / v2 / Docker / runner (push) Successful in 36s
Release / v2 / Docker / backend (push) Successful in 1m34s
iOS CI / Build (pull_request) Successful in 5m33s
iOS CI / Test (pull_request) Successful in 11m25s
- Runner fetches 9 browse combos (genre×sort×status) every 6h and stores JSON snapshots in MinIO libnovel-browse bucket (browse_refresh.go) - Backend handleBrowse reads page-1 results from MinIO first; falls back to live novelfire.net fetch; returns empty+cached:false on total failure instead of 502 - Add BrowseStore interface (bookstore.go), MinIO put/get helpers (minio.go), Store methods + compile-time assertion (store.go), BucketBrowse config, wiring in cmd/backend and cmd/runner, docker-compose-new bucket init - Fix ReapStaleTasks: PocketBase datetime fields require heartbeat_at=null (not heartbeat_at="") in filter expressions, and nil (not "") in patch payload — was causing 400 errors on every reap cycle
127 lines
4.0 KiB
Go
127 lines
4.0 KiB
Go
// Command backend is the LibNovel HTTP API server.
|
|
//
|
|
// It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads,
|
|
// scrape-task creation, presigned MinIO URLs, audio-task creation, reading
|
|
// progress, live novelfire.net browse/search, and Kokoro voice list.
|
|
//
|
|
// All heavy lifting (scraping, TTS generation) is delegated to the runner
|
|
// binary via PocketBase task records. The backend never scrapes directly.
|
|
//
|
|
// Usage:
|
|
//
|
|
// backend # start HTTP server (blocks until SIGINT/SIGTERM)
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"github.com/libnovel/backend/internal/backend"
|
|
"github.com/libnovel/backend/internal/config"
|
|
"github.com/libnovel/backend/internal/kokoro"
|
|
"github.com/libnovel/backend/internal/storage"
|
|
)
|
|
|
|
// version and commit are set at build time via -ldflags.
|
|
var (
|
|
version = "dev"
|
|
commit = "unknown"
|
|
)
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "backend: fatal: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
cfg := config.Load()
|
|
|
|
// ── Logger ───────────────────────────────────────────────────────────────
|
|
log := buildLogger(cfg.LogLevel)
|
|
log.Info("backend starting",
|
|
"version", version,
|
|
"commit", commit,
|
|
"addr", cfg.HTTP.Addr,
|
|
)
|
|
|
|
// ── Context: cancel on SIGINT / SIGTERM ──────────────────────────────────
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// ── Storage ──────────────────────────────────────────────────────────────
|
|
store, err := storage.NewStore(ctx, cfg, log)
|
|
if err != nil {
|
|
return fmt.Errorf("init storage: %w", err)
|
|
}
|
|
|
|
// ── Kokoro (voice list only; audio generation is done by the runner) ─────
|
|
var kokoroClient kokoro.Client
|
|
if cfg.Kokoro.URL != "" {
|
|
kokoroClient = kokoro.New(cfg.Kokoro.URL)
|
|
log.Info("kokoro voices enabled", "url", cfg.Kokoro.URL)
|
|
} else {
|
|
log.Info("KOKORO_URL not set — voice list will use built-in fallback")
|
|
kokoroClient = &noopKokoro{}
|
|
}
|
|
|
|
// ── Backend server ───────────────────────────────────────────────────────
|
|
srv := backend.New(
|
|
backend.Config{
|
|
Addr: cfg.HTTP.Addr,
|
|
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
|
Version: version,
|
|
Commit: commit,
|
|
},
|
|
backend.Dependencies{
|
|
BookReader: store,
|
|
RankingStore: store,
|
|
AudioStore: store,
|
|
PresignStore: store,
|
|
ProgressStore: store,
|
|
BrowseStore: store,
|
|
Producer: store,
|
|
TaskReader: store,
|
|
Kokoro: kokoroClient,
|
|
Log: log,
|
|
},
|
|
)
|
|
|
|
return srv.ListenAndServe(ctx)
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
func buildLogger(level string) *slog.Logger {
|
|
var lvl slog.Level
|
|
switch level {
|
|
case "debug":
|
|
lvl = slog.LevelDebug
|
|
case "warn":
|
|
lvl = slog.LevelWarn
|
|
case "error":
|
|
lvl = slog.LevelError
|
|
default:
|
|
lvl = slog.LevelInfo
|
|
}
|
|
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
|
|
}
|
|
|
|
// noopKokoro is a no-op implementation used when KOKORO_URL is not set.
|
|
// The backend only uses Kokoro for the voice list; audio generation is the
|
|
// runner's responsibility. With no URL the built-in fallback list is served.
|
|
type noopKokoro struct{}
|
|
|
|
func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) {
|
|
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
|
}
|
|
|
|
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
|
return nil, nil
|
|
}
|