Backend (handlers_catalogue.go):
- POST /api/admin/text-gen/tagline — 1-sentence marketing hook
- POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist
- POST /api/admin/text-gen/content-warnings — mature theme detection
- POST /api/admin/text-gen/quality-score — 1–5 description quality rating
- POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one
- POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry
- POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh
Frontend:
- text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete
- image-gen: localStorage style presets (save/apply/delete named prompt templates)
- catalogue-tools: new admin page with batch cover SSE progress + cancel
- admin nav: "Catalogue Tools" link added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
461 lines
17 KiB
Go
461 lines
17 KiB
Go
// Package backend implements the HTTP API server for the LibNovel backend.
|
|
//
|
|
// The server exposes all endpoints consumed by the SvelteKit UI:
|
|
// - Book/chapter reads from PocketBase/MinIO via bookstore interfaces
|
|
// - Task creation (scrape + audio) via taskqueue.Producer — the runner binary
|
|
// picks up and executes those tasks asynchronously
|
|
// - Presigned MinIO URLs for media playback/upload
|
|
// - Session-scoped reading progress
|
|
// - Live novelfire.net search (no scraper interface needed; direct HTTP)
|
|
// - Kokoro voice list
|
|
//
|
|
// The backend never scrapes directly. All scraping (metadata, chapter list,
|
|
// chapter text, audio TTS) is delegated to the runner binary via PocketBase
|
|
// task records. GET /api/book-preview enqueues a task when the book is absent.
|
|
//
|
|
// All external dependencies are injected as interfaces; concrete types live in
|
|
// internal/storage and are wired by cmd/backend/main.go.
|
|
package backend
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
|
"github.com/libnovel/backend/internal/bookstore"
|
|
"github.com/libnovel/backend/internal/cfai"
|
|
"github.com/libnovel/backend/internal/domain"
|
|
"github.com/libnovel/backend/internal/kokoro"
|
|
"github.com/libnovel/backend/internal/meili"
|
|
"github.com/libnovel/backend/internal/pockettts"
|
|
"github.com/libnovel/backend/internal/taskqueue"
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
)
|
|
|
|
// Dependencies holds all external services the backend server depends on.
|
|
// Every field is an interface so test doubles can be injected freely.
|
|
type Dependencies struct {
|
|
// BookReader reads book metadata and chapter text from PocketBase/MinIO.
|
|
BookReader bookstore.BookReader
|
|
// RankingStore reads ranking data from PocketBase.
|
|
RankingStore bookstore.RankingStore
|
|
// AudioStore checks audio object existence and computes MinIO keys.
|
|
AudioStore bookstore.AudioStore
|
|
// TranslationStore checks translation existence and reads/writes translated markdown.
|
|
TranslationStore bookstore.TranslationStore
|
|
// PresignStore generates short-lived MinIO URLs.
|
|
PresignStore bookstore.PresignStore
|
|
// ProgressStore reads/writes per-session reading progress.
|
|
ProgressStore bookstore.ProgressStore
|
|
// CoverStore reads and writes book cover images from MinIO.
|
|
// If nil, the cover endpoint falls back to a CDN redirect.
|
|
CoverStore bookstore.CoverStore
|
|
// Producer creates scrape/audio tasks in PocketBase.
|
|
Producer taskqueue.Producer
|
|
// TaskReader reads scrape/audio task records from PocketBase.
|
|
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 Kokoro TTS client (used for voice list only in the backend;
|
|
// audio generation is done by the runner).
|
|
Kokoro kokoro.Client
|
|
// PocketTTS is the pocket-tts client (used for voice list only in the backend;
|
|
// audio generation is done by the runner).
|
|
PocketTTS pockettts.Client
|
|
// CFAI is the Cloudflare Workers AI TTS client (used for voice sample
|
|
// generation and audio-stream live TTS; audio task generation is done by the runner).
|
|
CFAI cfai.Client
|
|
// ImageGen is the Cloudflare Workers AI image generation client.
|
|
// If nil, image generation endpoints return 503.
|
|
ImageGen cfai.ImageGenClient
|
|
// TextGen is the Cloudflare Workers AI text generation client.
|
|
// If nil, text generation endpoints return 503.
|
|
TextGen cfai.TextGenClient
|
|
// BookWriter writes book metadata and chapter refs to PocketBase.
|
|
// Used by admin text-gen apply endpoints.
|
|
BookWriter bookstore.BookWriter
|
|
// Log is the structured logger.
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Config holds HTTP server tuning parameters.
|
|
type Config struct {
|
|
// Addr is the listen address, e.g. ":8080".
|
|
Addr string
|
|
// DefaultVoice is used when no voice is specified in audio requests.
|
|
DefaultVoice string
|
|
// Version and Commit are embedded in /health and /api/version responses.
|
|
Version string
|
|
Commit string
|
|
}
|
|
|
|
// Server is the HTTP API server.
|
|
type Server struct {
|
|
cfg Config
|
|
deps Dependencies
|
|
|
|
// voiceMu guards cachedVoices. Populated lazily on first GET /api/voices.
|
|
voiceMu sync.RWMutex
|
|
cachedVoices []domain.Voice
|
|
}
|
|
|
|
// New creates a Server from cfg and deps.
|
|
func New(cfg Config, deps Dependencies) *Server {
|
|
if cfg.DefaultVoice == "" {
|
|
cfg.DefaultVoice = "af_bella"
|
|
}
|
|
if deps.Log == nil {
|
|
deps.Log = slog.Default()
|
|
}
|
|
if deps.SearchIndex == nil {
|
|
deps.SearchIndex = meili.NoopClient{}
|
|
}
|
|
return &Server{cfg: cfg, deps: deps}
|
|
}
|
|
|
|
// ListenAndServe registers all routes and starts the HTTP server.
|
|
// It blocks until ctx is cancelled, then performs a graceful shutdown.
|
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
|
mux := http.NewServeMux()
|
|
|
|
// Health / version
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /api/version", s.handleVersion)
|
|
|
|
// Scrape task creation (202 Accepted — runner executes asynchronously)
|
|
mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue)
|
|
mux.HandleFunc("POST /scrape/book", s.handleScrapeBook)
|
|
mux.HandleFunc("POST /scrape/book/range", s.handleScrapeBookRange)
|
|
|
|
// Scrape task status / history
|
|
mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus)
|
|
mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks)
|
|
|
|
// Cancel a pending task (scrape or audio)
|
|
mux.HandleFunc("POST /api/cancel-task/{id}", s.handleCancelTask)
|
|
|
|
// Browse & search
|
|
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)
|
|
mux.HandleFunc("GET /api/ranking", s.handleGetRanking)
|
|
|
|
// Cover proxy (live URL redirect)
|
|
mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover)
|
|
|
|
// Book preview (enqueues scrape task if not in library; returns stored data if already scraped)
|
|
mux.HandleFunc("GET /api/book-preview/{slug}", s.handleBookPreview)
|
|
|
|
// Chapter text (served from MinIO via PocketBase index)
|
|
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
|
// Raw markdown chapter content — served directly from MinIO by the backend.
|
|
// Use this instead of presign+fetch to avoid SvelteKit→MinIO network path.
|
|
mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown)
|
|
|
|
// 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
|
|
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
|
|
|
// Audio task creation (backend creates task; runner executes)
|
|
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
|
|
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
|
|
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
|
// Streaming audio: serves from MinIO if cached, else streams live TTS
|
|
// while simultaneously uploading to MinIO for future requests.
|
|
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
|
|
|
|
// Translation task creation (backend creates task; runner executes via LibreTranslate)
|
|
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
|
|
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
|
|
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
|
|
|
|
// Admin translation endpoints
|
|
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
|
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
|
|
|
// Admin audio endpoints
|
|
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
|
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
|
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
|
|
|
// Admin image generation endpoints
|
|
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
|
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
|
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
|
|
|
// Admin text generation endpoints (chapter names + book description)
|
|
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
|
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
|
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
|
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
|
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
|
|
|
// Admin catalogue enrichment endpoints
|
|
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
|
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
|
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
|
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
|
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
|
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
|
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
|
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
|
|
|
// Admin data repair endpoints
|
|
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
|
|
|
// Voices list
|
|
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
|
|
|
// Presigned URLs
|
|
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
|
|
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
|
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
|
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
|
|
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
|
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
|
|
|
// EPUB export
|
|
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
|
|
|
|
// Reading progress
|
|
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
|
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
|
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
|
|
|
|
// 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{
|
|
Addr: s.cfg.Addr,
|
|
Handler: handler,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Minute, // audio-stream can take several minutes for a full chapter
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- srv.ListenAndServe() }()
|
|
s.deps.Log.Info("backend: HTTP server listening", "addr", s.cfg.Addr)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
s.deps.Log.Info("backend: context cancelled, starting graceful shutdown")
|
|
shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutCtx); err != nil {
|
|
s.deps.Log.Error("backend: graceful shutdown failed", "err", err)
|
|
return err
|
|
}
|
|
s.deps.Log.Info("backend: shutdown complete")
|
|
return nil
|
|
case err := <-errCh:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ── Session cookie helpers ─────────────────────────────────────────────────────
|
|
|
|
const sessionCookieName = "libnovel_session"
|
|
|
|
func sessionID(r *http.Request) string {
|
|
c, err := r.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return c.Value
|
|
}
|
|
|
|
func newSessionID() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func ensureSession(w http.ResponseWriter, r *http.Request) string {
|
|
if id := sessionID(r); id != "" {
|
|
return id
|
|
}
|
|
id, err := newSessionID()
|
|
if err != nil {
|
|
id = fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: id,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 365 * 24 * 60 * 60,
|
|
})
|
|
return id
|
|
}
|
|
|
|
// ── Utility helpers ────────────────────────────────────────────────────────────
|
|
|
|
// writeJSON writes v as a JSON response with status code. Status 0 → 200.
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if status != 0 {
|
|
w.WriteHeader(status)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// jsonError writes a JSON error body and the given status code.
|
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|
|
|
|
// voices returns the merged list of available voices from Kokoro and pocket-tts.
|
|
// On the first call it fetches from both services and caches the result.
|
|
// Falls back to the hardcoded Kokoro list on error.
|
|
func (s *Server) voices(ctx context.Context) []domain.Voice {
|
|
s.voiceMu.RLock()
|
|
cached := s.cachedVoices
|
|
s.voiceMu.RUnlock()
|
|
if len(cached) > 0 {
|
|
return cached
|
|
}
|
|
|
|
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
var result []domain.Voice
|
|
|
|
// ── Kokoro voices ─────────────────────────────────────────────────────────
|
|
var kokoroIDs []string
|
|
if s.deps.Kokoro != nil {
|
|
ids, err := s.deps.Kokoro.ListVoices(fetchCtx)
|
|
if err != nil || len(ids) == 0 {
|
|
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
|
|
ids = kokoroVoiceIDs
|
|
} else {
|
|
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(ids))
|
|
}
|
|
kokoroIDs = ids
|
|
} else {
|
|
kokoroIDs = kokoroVoiceIDs
|
|
}
|
|
for _, id := range kokoroIDs {
|
|
result = append(result, kokoroVoice(id))
|
|
}
|
|
|
|
// ── Pocket-TTS voices ─────────────────────────────────────────────────────
|
|
if s.deps.PocketTTS != nil {
|
|
ids, err := s.deps.PocketTTS.ListVoices(fetchCtx)
|
|
if err != nil {
|
|
s.deps.Log.Warn("backend: could not fetch pocket-tts voices", "err", err)
|
|
} else {
|
|
for _, id := range ids {
|
|
result = append(result, pocketTTSVoice(id))
|
|
}
|
|
s.deps.Log.Info("backend: fetched pocket-tts voices", "count", len(ids))
|
|
}
|
|
}
|
|
|
|
// ── Cloudflare AI voices ──────────────────────────────────────────────────
|
|
if s.deps.CFAI != nil {
|
|
for _, speaker := range cfai.Speakers() {
|
|
gender := "m"
|
|
if cfai.IsFemale(speaker) {
|
|
gender = "f"
|
|
}
|
|
result = append(result, domain.Voice{
|
|
ID: cfai.VoiceID(speaker),
|
|
Engine: "cfai",
|
|
Lang: "en",
|
|
Gender: gender,
|
|
})
|
|
}
|
|
s.deps.Log.Info("backend: loaded CF AI voices", "count", len(cfai.Speakers()))
|
|
}
|
|
|
|
s.voiceMu.Lock()
|
|
s.cachedVoices = result
|
|
s.voiceMu.Unlock()
|
|
return result
|
|
}
|
|
|
|
// kokoroVoice builds a domain.Voice for a Kokoro voice ID.
|
|
// The two-character prefix encodes language and gender:
|
|
//
|
|
// af/am → en-us f/m | bf/bm → en-gb f/m
|
|
// ef/em → es f/m | ff → fr f
|
|
// hf/hm → hi f/m | if/im → it f/m
|
|
// jf/jm → ja f/m | pf/pm → pt f/m
|
|
// zf/zm → zh f/m
|
|
func kokoroVoice(id string) domain.Voice {
|
|
type meta struct{ lang, gender string }
|
|
prefixMap := map[string]meta{
|
|
"af": {"en-us", "f"}, "am": {"en-us", "m"},
|
|
"bf": {"en-gb", "f"}, "bm": {"en-gb", "m"},
|
|
"ef": {"es", "f"}, "em": {"es", "m"},
|
|
"ff": {"fr", "f"},
|
|
"hf": {"hi", "f"}, "hm": {"hi", "m"},
|
|
"if": {"it", "f"}, "im": {"it", "m"},
|
|
"jf": {"ja", "f"}, "jm": {"ja", "m"},
|
|
"pf": {"pt", "f"}, "pm": {"pt", "m"},
|
|
"zf": {"zh", "f"}, "zm": {"zh", "m"},
|
|
}
|
|
if len(id) >= 2 {
|
|
if m, ok := prefixMap[id[:2]]; ok {
|
|
return domain.Voice{ID: id, Engine: "kokoro", Lang: m.lang, Gender: m.gender}
|
|
}
|
|
}
|
|
return domain.Voice{ID: id, Engine: "kokoro", Lang: "en", Gender: ""}
|
|
}
|
|
|
|
// pocketTTSVoice builds a domain.Voice for a pocket-tts voice ID.
|
|
// All pocket-tts voices are English audiobook narrators.
|
|
func pocketTTSVoice(id string) domain.Voice {
|
|
femaleVoices := map[string]struct{}{
|
|
"alba": {}, "fantine": {}, "cosette": {}, "eponine": {},
|
|
"azelma": {}, "anna": {}, "vera": {}, "mary": {}, "jane": {}, "eve": {},
|
|
}
|
|
gender := "m"
|
|
if _, ok := femaleVoices[id]; ok {
|
|
gender = "f"
|
|
}
|
|
return domain.Voice{ID: id, Engine: "pocket-tts", Lang: "en", Gender: gender}
|
|
}
|
|
|
|
// handleHealth handles GET /health.
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, 0, map[string]string{
|
|
"status": "ok",
|
|
"version": s.cfg.Version,
|
|
"commit": s.cfg.Commit,
|
|
})
|
|
}
|
|
|
|
// handleVersion handles GET /api/version.
|
|
func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, 0, map[string]string{
|
|
"version": s.cfg.Version,
|
|
"commit": s.cfg.Commit,
|
|
})
|
|
}
|