// Package server exposes the scraper as an HTTP API service. // // Endpoints: // // POST /scrape — enqueue a full catalogue scrape // POST /scrape/book — enqueue a single-book scrape (JSON body: {"url":"..."}) // GET /health — liveness probe // GET /api/progress — get reading progress map (session-scoped) // POST /api/progress/{slug} — set reading progress // DELETE /api/progress/{slug} — delete reading progress // GET /api/presign/chapter/{slug}/{n} — presigned MinIO URL for chapter markdown // GET /api/presign/audio/{slug}/{n} — presigned MinIO URL for chapter audio // GET /api/chapter-text/{slug}/{n} — plain text of chapter (markdown stripped) // POST /api/audio/{slug}/{n} — trigger Kokoro audio generation // GET /api/audio-proxy/{slug}/{n} — proxy generated audio from Kokoro package server import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "sync" "time" "github.com/libnovel/scraper/internal/orchestrator" "github.com/libnovel/scraper/internal/scraper" "github.com/libnovel/scraper/internal/storage" "golang.org/x/net/html" ) // Server wraps an HTTP mux with the scraping endpoints. type Server struct { addr string oCfg orchestrator.Config novel scraper.NovelScraper log *slog.Logger store storage.Store mu sync.Mutex running bool kokoroURL string // Kokoro-FastAPI base URL, e.g. http://kokoro:8880 kokoroVoice string // default voice, e.g. af_bella // voiceMu guards cachedVoices. voiceMu sync.RWMutex cachedVoices []string // populated on first request from Kokoro /v1/audio/voices // audioMu guards audioInFlight only. // Completed audio filenames are persisted to the Store (PocketBase). // audioInFlight deduplicates concurrent generation requests for the same key. audioMu sync.Mutex audioInFlight map[string]chan struct{} // cacheKey → closed when done // browseMu guards browseInFlight — keys currently being refreshed // in the background. browseMu sync.Mutex browseInFlight map[string]struct{} // browseMemCache is a short-lived in-process cache for browse results. // It is populated whenever a live upstream fetch succeeds and used as a // last-resort fallback when both MinIO and the upstream are unavailable. // Key: the MinIO cache key (same as used for BrowseHTMLKey). browseMemCacheMu sync.RWMutex browseMemCache map[string]browseCacheEntry } type browseCacheEntry struct { novels []NovelListing hasNext bool cachedAt time.Time } // New creates a new Server. func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server { return &Server{ addr: addr, oCfg: oCfg, novel: novel, log: log, store: store, kokoroURL: kokoroURL, kokoroVoice: kokoroVoice, audioInFlight: make(map[string]chan struct{}), browseInFlight: make(map[string]struct{}), browseMemCache: make(map[string]browseCacheEntry), } } // voices returns the list of available Kokoro voices. On the first call it // fetches GET /v1/audio/voices from the Kokoro service and caches the result. // If the fetch fails (Kokoro not up yet, network error, etc.) it falls back to // the hardcoded kokoroVoices list so the UI is never empty. func (s *Server) voices() []string { s.voiceMu.RLock() cached := s.cachedVoices s.voiceMu.RUnlock() if len(cached) > 0 { return cached } if s.kokoroURL != "" { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil) if err == nil { req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err == nil { defer resp.Body.Close() var payload struct { Voices []string `json:"voices"` } if resp.StatusCode == http.StatusOK && json.NewDecoder(resp.Body).Decode(&payload) == nil && len(payload.Voices) > 0 { s.voiceMu.Lock() s.cachedVoices = payload.Voices s.voiceMu.Unlock() s.log.Info("fetched kokoro voices", "count", len(payload.Voices)) return payload.Voices } } } s.log.Warn("could not fetch kokoro voices, using built-in list") } return kokoroVoices } // ListenAndServe starts the HTTP server and blocks until the provided context // is cancelled. func (s *Server) ListenAndServe(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("POST /scrape", s.handleScrapeCatalogue) mux.HandleFunc("POST /scrape/book", s.handleScrapeBook) // Browse API — fetches and parses novelfire catalogue page mux.HandleFunc("GET /api/browse", s.handleBrowse) // Ranking API mux.HandleFunc("GET /api/ranking", s.handleGetRanking) // Cover image proxy (serves images stored in browse MinIO bucket) mux.HandleFunc("GET /api/cover/{domain}/{slug}", s.handleGetCover) // Scrape status mux.HandleFunc("GET /api/scrape/status", s.handleScrapeStatus) mux.HandleFunc("GET /api/scrape/tasks", s.handleScrapeTasks) // Re-index chapters for a book from MinIO into PocketBase chapters_idx mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex) // Progress API mux.HandleFunc("GET /api/progress", s.handleGetProgress) mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress) mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress) // Presigned URL API (for SvelteKit UI) 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) // Plain-text chapter content (used server-side for audio generation) mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText) // Voices list (proxied from Kokoro) mux.HandleFunc("GET /api/voices", s.handleVoices) // Voice sample generation — generates a short audio clip for each voice // and stores it in MinIO for UI preview playback. voiceSampleHandler := http.TimeoutHandler( http.HandlerFunc(s.handleGenerateVoiceSamples), 15*time.Minute, `{"error":"voice sample generation timed out"}`, ) mux.Handle("POST /api/audio/voice-samples", voiceSampleHandler) // Server-side audio generation via Kokoro /v1/audio/speech. // Generation can take several minutes, so wrap in its own timeout handler. audioGenHandler := http.TimeoutHandler( http.HandlerFunc(s.handleAudioGenerate), 10*time.Minute, `{"error":"audio generation timed out"}`, ) mux.Handle("POST /api/audio/{slug}/{n}", audioGenHandler) // Proxy route: fetches the generated file from Kokoro /v1/download/{filename}. mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy) srv := &http.Server{ Addr: s.addr, Handler: mux, ReadTimeout: 15 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) go func() { errCh <- srv.ListenAndServe() }() s.log.Info("HTTP server listening", "addr", s.addr) // Pre-populate voice samples in the background so the UI voice selector // has playable previews without requiring a manual trigger. go s.warmVoiceSamples(ctx) // Warm the browse cache on startup: if page 1 is not cached in MinIO yet, // trigger a background SingleFile snapshot immediately so the first user // request is served from cache rather than hitting novelfire.net live. go s.warmBrowseCache() select { case <-ctx.Done(): shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(shutCtx) case err := <-errCh: return err } } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } // handleGetRanking returns all ranking items sorted by rank ascending. // Cover fields that hold a MinIO object key (e.g. "novelfire.net/assets/book-covers/slug.jpg") // are rewritten to a /api/cover/{key} proxy URL so the UI can fetch them // without knowing about the internal MinIO topology. func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) { items, err := s.store.ReadRankingItems(r.Context()) if err != nil { s.log.Error("ranking read failed", "err", err) http.Error(w, `{"error":"failed to read ranking"}`, http.StatusInternalServerError) return } if items == nil { items = []storage.RankingItem{} } // Rewrite cover keys to proxy URLs. // Keys stored by triggerDirectScrape look like: // "novelfire.net/assets/book-covers/shadow-slave.jpg" // We expose them as: // "/api/cover/novelfire.net/shadow-slave" // (the handler strips the domain and slug from the path, reconstructs the key) for i := range items { cover := items[i].Cover if cover != "" && !strings.HasPrefix(cover, "http") { // cover is a MinIO key; extract domain + slug for the proxy path. // Key format: {domain}/assets/book-covers/{slug}.jpg parts := strings.SplitN(cover, "/assets/book-covers/", 2) if len(parts) == 2 { domain := parts[0] slug := strings.TrimSuffix(parts[1], ".jpg") items[i].Cover = "/api/cover/" + domain + "/" + slug } } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(items) } // handleGetCover proxies a cover image stored in the MinIO browse bucket. // Route: GET /api/cover/{domain}/{slug} // It reconstructs the MinIO key as {domain}/assets/book-covers/{slug}.jpg, // fetches the object, and streams it to the client. // Returns 404 if not yet downloaded, allowing the UI to fall back to the // original source URL. func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) { domain := r.PathValue("domain") slug := r.PathValue("slug") if domain == "" || slug == "" { http.Error(w, "missing domain or slug", http.StatusBadRequest) return } key := s.store.BrowseCoverKey(domain, slug) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() data, contentType, ok, err := s.store.GetBrowseAsset(ctx, key) if err != nil { s.log.Warn("handleGetCover: GetBrowseAsset error", "key", key, "err", err) http.Error(w, "storage error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } if contentType == "" { contentType = "image/jpeg" } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write(data) } // ─── Session cookie helpers ─────────────────────────────────────────────────── const sessionCookieName = "libnovel_session" // sessionID returns the session ID from the request cookie, or "" if absent. func sessionID(r *http.Request) string { c, err := r.Cookie(sessionCookieName) if err != nil { return "" } return c.Value } // newSessionID generates a random 16-byte hex session ID. func newSessionID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // ensureSession issues a new session cookie if the request does not already // carry one, and returns the session ID (either existing or newly issued). func ensureSession(w http.ResponseWriter, r *http.Request) string { if id := sessionID(r); id != "" { return id } id, err := newSessionID() if err != nil { // Very unlikely, but fall back to a timestamp-based ID. 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, // 1 year }) return id } // ─── Reading progress API ───────────────────────────────────────────────────── // handleGetProgress handles GET /api/progress. // Returns JSON: {"slug": chapterNum, ...} merged with {"slug_ts": timestampMs, ...} func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) { sid := ensureSession(w, r) entries, err := s.store.AllProgress(r.Context(), sid) if err != nil { s.log.Error("AllProgress failed", "err", err) entries = nil } progress := make(map[string]interface{}, len(entries)*2) for _, p := range entries { progress[p.Slug] = p.Chapter progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli() } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(progress) } // handleSetProgress handles POST /api/progress/{slug}. // Body: {"chapter": N} func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) { sid := ensureSession(w, r) slug := r.PathValue("slug") if slug == "" { http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) return } var body struct { Chapter int `json:"chapter"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 { http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest) return } p := storage.ReadingProgress{ Slug: slug, Chapter: body.Chapter, UpdatedAt: time.Now(), } if err := s.store.SetProgress(r.Context(), sid, p); err != nil { s.log.Error("SetProgress failed", "slug", slug, "err", err) http.Error(w, `{"error":"store error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{}) } // handleDeleteProgress handles DELETE /api/progress/{slug}. func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) { sid := ensureSession(w, r) slug := r.PathValue("slug") if slug == "" { http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest) return } if err := s.store.DeleteProgress(r.Context(), sid, slug); err != nil { s.log.Error("DeleteProgress failed", "slug", slug, "err", err) // Non-fatal — treat as success. } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{}) } // handleChapterText returns the plain text of a chapter (markdown stripped) // for server-side audio generation. Called by handleAudioGenerate internally. func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") n, err := strconv.Atoi(r.PathValue("n")) if err != nil || n < 1 { http.NotFound(w, r) return } raw, err := s.store.ReadChapter(r.Context(), slug, n) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") fmt.Fprint(w, stripMarkdown(raw)) } // ─── Audio generation via Kokoro /v1/audio/speech ──────────────────────────── // // handleAudioGenerate handles POST /api/audio/{slug}/{n}. // // It calls Kokoro's POST /v1/audio/speech with return_download_link=true. // Kokoro generates the audio, saves it to its own temp storage, and returns // the download filename in the X-Download-Path response header. // We cache that filename (in memory, keyed by slug/chapter/voice) and // return a proxy URL that the browser sets as audio.src. // // TTS is always generated at speed 1.0; playback speed is controlled // client-side via the