Adds BACKEND_ADMIN_TOKEN env var (set in Doppler) as a required Bearer token for every admin route. Also fixes PocketBase filter injection in notification queries and wires BACKEND_ADMIN_TOKEN through docker-compose to both backend and ui services. Includes CLAUDE.md for AI assistant guidance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
261 lines
9.6 KiB
Go
261 lines
9.6 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 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"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/hibiken/asynq"
|
|
"github.com/libnovel/backend/internal/asynqqueue"
|
|
"github.com/libnovel/backend/internal/backend"
|
|
"github.com/libnovel/backend/internal/cfai"
|
|
"github.com/libnovel/backend/internal/config"
|
|
"github.com/libnovel/backend/internal/kokoro"
|
|
"github.com/libnovel/backend/internal/meili"
|
|
"github.com/libnovel/backend/internal/otelsetup"
|
|
"github.com/libnovel/backend/internal/pockettts"
|
|
"github.com/libnovel/backend/internal/storage"
|
|
"github.com/libnovel/backend/internal/taskqueue"
|
|
)
|
|
|
|
// 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()
|
|
|
|
// ── 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 ───────────────────────────────────────────────────────────────
|
|
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()
|
|
|
|
// ── 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 ──────────────────────────────────────────────────────────────
|
|
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{}
|
|
}
|
|
|
|
// ── Pocket-TTS (voice list + sample generation; audio generation is the runner's job) ──
|
|
var pocketTTSClient pockettts.Client
|
|
if cfg.PocketTTS.URL != "" {
|
|
pocketTTSClient = pockettts.New(cfg.PocketTTS.URL)
|
|
log.Info("pocket-tts voices enabled", "url", cfg.PocketTTS.URL)
|
|
} else {
|
|
log.Info("POCKET_TTS_URL not set — pocket-tts voices unavailable in backend")
|
|
}
|
|
|
|
// ── Cloudflare Workers AI (voice sample generation + audio-stream live TTS) ──
|
|
var cfaiClient cfai.Client
|
|
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
|
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
|
|
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
|
|
} else {
|
|
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voices unavailable in backend")
|
|
}
|
|
|
|
// ── Cloudflare Workers AI Image Generation ────────────────────────────────
|
|
var imageGenClient cfai.ImageGenClient
|
|
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
|
imageGenClient = cfai.NewImageGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
|
|
log.Info("cloudflare AI image generation enabled")
|
|
} else {
|
|
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable")
|
|
}
|
|
|
|
// ── Cloudflare Workers AI Text Generation ─────────────────────────────────
|
|
var textGenClient cfai.TextGenClient
|
|
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
|
textGenClient = cfai.NewTextGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
|
|
log.Info("cloudflare AI text generation enabled")
|
|
} else {
|
|
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — text generation unavailable")
|
|
}
|
|
|
|
// ── 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{}
|
|
}
|
|
|
|
// ── Task Producer ────────────────────────────────────────────────────────
|
|
// When REDIS_ADDR is set the backend dual-writes: PocketBase record (audit)
|
|
// + Asynq job (immediate delivery). Otherwise it writes to PocketBase only
|
|
// and the runner picks up on the next poll tick.
|
|
var producer taskqueue.Producer = store
|
|
if cfg.Redis.Addr != "" {
|
|
redisOpt, parseErr := parseRedisOpt(cfg.Redis)
|
|
if parseErr != nil {
|
|
return fmt.Errorf("parse REDIS_ADDR: %w", parseErr)
|
|
}
|
|
asynqProducer := asynqqueue.NewProducer(store, redisOpt, log)
|
|
defer asynqProducer.Close() //nolint:errcheck
|
|
producer = asynqProducer
|
|
log.Info("backend: asynq task dispatch enabled", "addr", cfg.Redis.Addr)
|
|
} else {
|
|
log.Info("backend: poll-mode task dispatch (REDIS_ADDR not set)")
|
|
}
|
|
|
|
// ── Backend server ───────────────────────────────────────────────────────
|
|
srv := backend.New(
|
|
backend.Config{
|
|
Addr: cfg.HTTP.Addr,
|
|
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
|
Version: version,
|
|
Commit: commit,
|
|
AdminToken: cfg.HTTP.AdminToken,
|
|
},
|
|
backend.Dependencies{
|
|
BookReader: store,
|
|
RankingStore: store,
|
|
AudioStore: store,
|
|
TranslationStore: store,
|
|
PresignStore: store,
|
|
ProgressStore: store,
|
|
CoverStore: store,
|
|
ChapterImageStore: store,
|
|
Producer: producer,
|
|
TaskReader: store,
|
|
ImportFileStore: store,
|
|
SearchIndex: searchIndex,
|
|
Kokoro: kokoroClient,
|
|
PocketTTS: pocketTTSClient,
|
|
CFAI: cfaiClient,
|
|
ImageGen: imageGenClient,
|
|
TextGen: textGenClient,
|
|
BookWriter: store,
|
|
AIJobStore: store,
|
|
BookAdminStore: store,
|
|
NotificationStore: store,
|
|
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) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
|
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
|
}
|
|
|
|
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
|
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
|
}
|
|
|
|
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// parseRedisOpt converts a config.Redis into an asynq.RedisConnOpt.
|
|
// Handles full "redis://" / "rediss://" URLs and plain "host:port".
|
|
func parseRedisOpt(cfg config.Redis) (asynq.RedisConnOpt, error) {
|
|
addr := cfg.Addr
|
|
if len(addr) > 7 && (addr[:8] == "redis://" || (len(addr) > 8 && addr[:9] == "rediss://")) {
|
|
return asynq.ParseRedisURI(addr)
|
|
}
|
|
return asynq.RedisClientOpt{
|
|
Addr: addr,
|
|
Password: cfg.Password,
|
|
}, nil
|
|
}
|