feat(v3): add v3 stack — backend rewrite, renamed env vars, docs

- New Go backend binary (backend + runner) replacing old scraper/
- Rename SCRAPER_API_URL → BACKEND_API_URL in UI env and docker-compose
- Rename scraperFetch → backendFetch across all 19 UI server files
- Remove SCRAPER_PROXY env var and proxy transport from browser.Config
- Add Meilisearch, Valkey, Caddy to docker-compose
- Add docs/: api-endpoints.md, request-flow.mermaid.md, data-flow.mermaid.md
This commit is contained in:
Admin
2026-03-22 17:27:32 +05:00
parent 29d0eeb7e8
commit a85636d5db
178 changed files with 25951 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
// 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 browse/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"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/taskqueue"
)
// 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
// PresignStore generates short-lived MinIO URLs.
PresignStore bookstore.PresignStore
// ProgressStore reads/writes per-session reading progress.
ProgressStore bookstore.ProgressStore
// BrowseStore reads cached browse page snapshots from MinIO.
BrowseStore bookstore.BrowseStore
// 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 TTS client (used for voice list only in the backend;
// audio generation is done by the runner).
Kokoro kokoro.Client
// 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 []string
}
// 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 (live novelfire.net)
mux.HandleFunc("GET /api/browse", s.handleBrowse)
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)
// 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)
// 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)
// 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)
srv := &http.Server{
Addr: s.cfg.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.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 list of available Kokoro voices. On the first call it
// fetches from the Kokoro service and caches the result. Falls back to the
// hardcoded list on error.
func (s *Server) voices(ctx context.Context) []string {
s.voiceMu.RLock()
cached := s.cachedVoices
s.voiceMu.RUnlock()
if len(cached) > 0 {
return cached
}
if s.deps.Kokoro == nil {
return kokoroVoices
}
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
list, err := s.deps.Kokoro.ListVoices(fetchCtx)
if err != nil || len(list) == 0 {
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
return kokoroVoices
}
s.voiceMu.Lock()
s.cachedVoices = list
s.voiceMu.Unlock()
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(list))
return list
}
// 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,
})
}

View File

@@ -0,0 +1,151 @@
// Package bookstore defines the segregated read/write interfaces for book,
// chapter, ranking, progress, audio, and presign data.
//
// Interface segregation:
// - BookWriter — used by the runner to persist scraped data.
// - BookReader — used by the backend to serve book/chapter data.
// - RankingStore — used by both runner (write) and backend (read).
// - PresignStore — used only by the backend for URL signing.
// - AudioStore — used by the runner to store audio; backend for presign.
// - ProgressStore— used only by the backend for reading progress.
//
// Concrete implementations live in internal/storage.
package bookstore
import (
"context"
"time"
"github.com/libnovel/backend/internal/domain"
)
// BookWriter is the write side used by the runner after scraping a book.
type BookWriter interface {
// WriteMetadata upserts all bibliographic fields for a book.
WriteMetadata(ctx context.Context, meta domain.BookMeta) error
// WriteChapter stores a fully-scraped chapter's text in MinIO and
// updates the chapters_idx record in PocketBase.
WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error
// WriteChapterRefs persists chapter metadata (number + title) into
// chapters_idx without fetching or storing chapter text.
WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error
// ChapterExists returns true if the markdown object for ref already exists.
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
}
// BookReader is the read side used by the backend to serve content.
type BookReader interface {
// ReadMetadata returns the metadata for slug.
// Returns (zero, false, nil) when not found.
ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error)
// ListBooks returns all books sorted alphabetically by title.
ListBooks(ctx context.Context) ([]domain.BookMeta, error)
// LocalSlugs returns the set of slugs that have metadata stored.
LocalSlugs(ctx context.Context) (map[string]bool, error)
// MetadataMtime returns the Unix-second mtime of the metadata record, or 0.
MetadataMtime(ctx context.Context, slug string) int64
// ReadChapter returns the raw markdown for chapter number n.
ReadChapter(ctx context.Context, slug string, n int) (string, error)
// ListChapters returns all stored chapters for slug, sorted by number.
ListChapters(ctx context.Context, slug string) ([]domain.ChapterInfo, error)
// CountChapters returns the count of stored chapters.
CountChapters(ctx context.Context, slug string) int
// ReindexChapters rebuilds chapters_idx from MinIO objects for slug.
ReindexChapters(ctx context.Context, slug string) (int, error)
}
// RankingStore covers ranking reads and writes.
type RankingStore interface {
// WriteRankingItem upserts a single ranking entry (keyed on Slug).
WriteRankingItem(ctx context.Context, item domain.RankingItem) error
// ReadRankingItems returns all ranking items sorted by rank ascending.
ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error)
// RankingFreshEnough returns true when ranking rows exist and the most
// recent Updated timestamp is within maxAge.
RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error)
}
// AudioStore covers audio object storage (runner writes; backend reads).
type AudioStore interface {
// AudioObjectKey returns the MinIO object key for a cached audio file.
AudioObjectKey(slug string, n int, voice string) string
// AudioExists returns true when the audio object is present in MinIO.
AudioExists(ctx context.Context, key string) bool
// PutAudio stores raw audio bytes under the given MinIO object key.
PutAudio(ctx context.Context, key string, data []byte) error
}
// PresignStore generates short-lived URLs — used exclusively by the backend.
type PresignStore interface {
// PresignChapter returns a presigned GET URL for a chapter markdown object.
PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error)
// PresignAudio returns a presigned GET URL for an audio object.
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
// PresignAvatarUpload returns a short-lived presigned PUT URL for uploading
// an avatar image. ext should be "jpg", "png", or "webp".
PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error)
// PresignAvatarURL returns a presigned GET URL for a user's avatar.
// Returns ("", false, nil) when no avatar exists.
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
// DeleteAvatar removes all avatar objects for a user.
DeleteAvatar(ctx context.Context, userID string) error
}
// ProgressStore covers per-session reading progress — backend only.
type ProgressStore interface {
// GetProgress returns the reading progress for the given session + slug.
GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool)
// SetProgress saves or updates reading progress.
SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error
// AllProgress returns all progress entries for a session.
AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error)
// DeleteProgress removes progress for a specific slug.
DeleteProgress(ctx context.Context, sessionID, slug string) error
}
// BrowseStore covers browse page snapshot storage.
// The runner writes snapshots; the backend reads them.
type BrowseStore interface {
// PutBrowsePage stores a raw JSON snapshot for a browse page.
// genre, sort, status, novelType and page identify the page.
PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error
// GetBrowsePage retrieves a raw JSON snapshot. Returns (nil, false, nil)
// when no snapshot exists for the given parameters.
GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error)
}
// CoverStore covers book cover image storage in MinIO.
// The runner writes covers during catalogue refresh; the backend reads them.
type CoverStore interface {
// PutCover stores a raw cover image for a book identified by slug.
PutCover(ctx context.Context, slug string, data []byte, contentType string) error
// GetCover retrieves the cover image for a book. Returns (nil, false, nil)
// when no cover exists for the given slug.
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
}

View File

@@ -0,0 +1,138 @@
package bookstore_test
import (
"context"
"testing"
"time"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
)
// ── Mock that satisfies all bookstore interfaces ──────────────────────────────
type mockStore struct{}
// BookWriter
func (m *mockStore) WriteMetadata(_ context.Context, _ domain.BookMeta) error { return nil }
func (m *mockStore) WriteChapter(_ context.Context, _ string, _ domain.Chapter) error { return nil }
func (m *mockStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error {
return nil
}
func (m *mockStore) ChapterExists(_ context.Context, _ string, _ domain.ChapterRef) bool {
return false
}
// BookReader
func (m *mockStore) ReadMetadata(_ context.Context, _ string) (domain.BookMeta, bool, error) {
return domain.BookMeta{}, false, nil
}
func (m *mockStore) ListBooks(_ context.Context) ([]domain.BookMeta, error) { return nil, nil }
func (m *mockStore) LocalSlugs(_ context.Context) (map[string]bool, error) {
return map[string]bool{}, nil
}
func (m *mockStore) MetadataMtime(_ context.Context, _ string) int64 { return 0 }
func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, error) {
return "", nil
}
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
return nil, nil
}
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
// RankingStore
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
func (m *mockStore) ReadRankingItems(_ context.Context) ([]domain.RankingItem, error) {
return nil, nil
}
func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool, error) {
return false, nil
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
// PresignStore
func (m *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {
return "", nil
}
func (m *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
return "", nil
}
func (m *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
return "", "", nil
}
func (m *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
return "", false, nil
}
func (m *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
// ProgressStore
func (m *mockStore) GetProgress(_ context.Context, _, _ string) (domain.ReadingProgress, bool) {
return domain.ReadingProgress{}, false
}
func (m *mockStore) SetProgress(_ context.Context, _ string, _ domain.ReadingProgress) error {
return nil
}
func (m *mockStore) AllProgress(_ context.Context, _ string) ([]domain.ReadingProgress, error) {
return nil, nil
}
func (m *mockStore) DeleteProgress(_ context.Context, _, _ string) error { return nil }
// ── Compile-time interface satisfaction ───────────────────────────────────────
var _ bookstore.BookWriter = (*mockStore)(nil)
var _ bookstore.BookReader = (*mockStore)(nil)
var _ bookstore.RankingStore = (*mockStore)(nil)
var _ bookstore.AudioStore = (*mockStore)(nil)
var _ bookstore.PresignStore = (*mockStore)(nil)
var _ bookstore.ProgressStore = (*mockStore)(nil)
// ── Behavioural tests ─────────────────────────────────────────────────────────
func TestBookWriter_WriteMetadata_ReturnsNilError(t *testing.T) {
var w bookstore.BookWriter = &mockStore{}
if err := w.WriteMetadata(context.Background(), domain.BookMeta{Slug: "test"}); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestBookReader_ReadMetadata_NotFound(t *testing.T) {
var r bookstore.BookReader = &mockStore{}
_, found, err := r.ReadMetadata(context.Background(), "unknown")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if found {
t.Error("expected not found")
}
}
func TestRankingStore_RankingFreshEnough_ReturnsFalse(t *testing.T) {
var s bookstore.RankingStore = &mockStore{}
fresh, err := s.RankingFreshEnough(context.Background(), time.Hour)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fresh {
t.Error("expected false")
}
}
func TestAudioStore_AudioExists_ReturnsFalse(t *testing.T) {
var s bookstore.AudioStore = &mockStore{}
if s.AudioExists(context.Background(), "audio/slug/1/af_bella.mp3") {
t.Error("expected false")
}
}
func TestProgressStore_GetProgress_NotFound(t *testing.T) {
var s bookstore.ProgressStore = &mockStore{}
_, found := s.GetProgress(context.Background(), "session-1", "slug")
if found {
t.Error("expected not found")
}
}

View File

@@ -0,0 +1,191 @@
// Package browser provides a rate-limited HTTP client for web scraping.
package browser
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
)
// ErrRateLimit is returned by GetContent when the server responds with 429.
// It carries the suggested retry delay (from Retry-After header, or a default).
var ErrRateLimit = errors.New("rate limited (429)")
// RateLimitError wraps ErrRateLimit and carries the suggested wait duration.
type RateLimitError struct {
// RetryAfter is how long the caller should wait before retrying.
// Derived from the Retry-After response header when present; otherwise a default.
RetryAfter time.Duration
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited (429): retry after %s", e.RetryAfter)
}
func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimit }
// defaultRateLimitDelay is used when the server returns 429 with no Retry-After header.
const defaultRateLimitDelay = 60 * time.Second
// Client is the interface used by scrapers to fetch raw page HTML.
// Implementations must be safe for concurrent use.
type Client interface {
// GetContent fetches the URL and returns the full response body as a string.
// It should respect the provided context for cancellation and timeouts.
GetContent(ctx context.Context, pageURL string) (string, error)
}
// Config holds tunable parameters for the direct HTTP client.
type Config struct {
// MaxConcurrent limits the number of simultaneous in-flight requests.
// Defaults to 5 when 0.
MaxConcurrent int
// Timeout is the per-request deadline. Defaults to 90s when 0.
Timeout time.Duration
}
// DirectClient is a plain net/http-based Client with a concurrency semaphore.
type DirectClient struct {
http *http.Client
semaphore chan struct{}
}
// NewDirectClient returns a DirectClient configured by cfg.
func NewDirectClient(cfg Config) *DirectClient {
if cfg.MaxConcurrent <= 0 {
cfg.MaxConcurrent = 5
}
if cfg.Timeout <= 0 {
cfg.Timeout = 90 * time.Second
}
transport := &http.Transport{
MaxIdleConnsPerHost: cfg.MaxConcurrent * 2,
DisableCompression: false,
}
return &DirectClient{
http: &http.Client{
Transport: transport,
Timeout: cfg.Timeout,
},
semaphore: make(chan struct{}, cfg.MaxConcurrent),
}
}
// GetContent fetches pageURL respecting the concurrency limit.
func (c *DirectClient) GetContent(ctx context.Context, pageURL string) (string, error) {
// Acquire semaphore slot.
select {
case c.semaphore <- struct{}{}:
case <-ctx.Done():
return "", ctx.Err()
}
defer func() { <-c.semaphore }()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return "", fmt.Errorf("browser: build request %s: %w", pageURL, 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.5")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("browser: GET %s: %w", pageURL, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
delay := defaultRateLimitDelay
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
delay = time.Duration(secs) * time.Second
}
}
return "", &RateLimitError{RetryAfter: delay}
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("browser: GET %s returned %d", pageURL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("browser: read body %s: %w", pageURL, err)
}
return string(body), nil
}
// Do implements httputil.Client so DirectClient can be passed to RetryGet.
func (c *DirectClient) Do(req *http.Request) (*http.Response, error) {
select {
case c.semaphore <- struct{}{}:
case <-req.Context().Done():
return nil, req.Context().Err()
}
defer func() { <-c.semaphore }()
return c.http.Do(req)
}
// ── Stub for testing ──────────────────────────────────────────────────────────
// StubClient is a test double for Client. It returns pre-configured responses
// keyed on URL. Calls to unknown URLs return an error.
type StubClient struct {
mu sync.Mutex
pages map[string]string
errors map[string]error
callLog []string
}
// NewStub creates a StubClient with no pages pre-loaded.
func NewStub() *StubClient {
return &StubClient{
pages: make(map[string]string),
errors: make(map[string]error),
}
}
// SetPage registers a URL → HTML body mapping.
func (s *StubClient) SetPage(u, html string) {
s.mu.Lock()
s.pages[u] = html
s.mu.Unlock()
}
// SetError registers a URL → error mapping (returned instead of a body).
func (s *StubClient) SetError(u string, err error) {
s.mu.Lock()
s.errors[u] = err
s.mu.Unlock()
}
// CallLog returns the ordered list of URLs that were requested.
func (s *StubClient) CallLog() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(s.callLog))
copy(out, s.callLog)
return out
}
// GetContent returns the registered page or an error for the URL.
func (s *StubClient) GetContent(_ context.Context, pageURL string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.callLog = append(s.callLog, pageURL)
if err, ok := s.errors[pageURL]; ok {
return "", err
}
if html, ok := s.pages[pageURL]; ok {
return html, nil
}
return "", fmt.Errorf("stub: no page registered for %q", pageURL)
}

View File

@@ -0,0 +1,141 @@
package browser_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/libnovel/backend/internal/browser"
)
func TestDirectClient_GetContent_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html>hello</html>"))
}))
defer srv.Close()
c := browser.NewDirectClient(browser.Config{MaxConcurrent: 2, Timeout: 5 * time.Second})
body, err := c.GetContent(context.Background(), srv.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body != "<html>hello</html>" {
t.Errorf("want <html>hello</html>, got %q", body)
}
}
func TestDirectClient_GetContent_4xxReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := browser.NewDirectClient(browser.Config{})
_, err := c.GetContent(context.Background(), srv.URL)
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestDirectClient_SemaphoreBlocksConcurrency(t *testing.T) {
const maxConcurrent = 2
var inflight atomic.Int32
var peak atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := inflight.Add(1)
if int(n) > int(peak.Load()) {
peak.Store(n)
}
time.Sleep(20 * time.Millisecond)
inflight.Add(-1)
w.Write([]byte("ok"))
}))
defer srv.Close()
c := browser.NewDirectClient(browser.Config{MaxConcurrent: maxConcurrent, Timeout: 5 * time.Second})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.GetContent(context.Background(), srv.URL)
}()
}
wg.Wait()
if int(peak.Load()) > maxConcurrent {
t.Errorf("concurrent requests exceeded limit: peak=%d, limit=%d", peak.Load(), maxConcurrent)
}
}
func TestDirectClient_ContextCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.Write([]byte("ok"))
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before making the request
c := browser.NewDirectClient(browser.Config{})
_, err := c.GetContent(ctx, srv.URL)
if err == nil {
t.Fatal("expected context cancellation error")
}
}
// ── StubClient ────────────────────────────────────────────────────────────────
func TestStubClient_ReturnsRegisteredPage(t *testing.T) {
stub := browser.NewStub()
stub.SetPage("http://example.com/page1", "<html>page1</html>")
body, err := stub.GetContent(context.Background(), "http://example.com/page1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body != "<html>page1</html>" {
t.Errorf("want page1 html, got %q", body)
}
}
func TestStubClient_ReturnsRegisteredError(t *testing.T) {
stub := browser.NewStub()
want := errors.New("network failure")
stub.SetError("http://example.com/bad", want)
_, err := stub.GetContent(context.Background(), "http://example.com/bad")
if err == nil {
t.Fatal("expected error")
}
}
func TestStubClient_UnknownURLReturnsError(t *testing.T) {
stub := browser.NewStub()
_, err := stub.GetContent(context.Background(), "http://unknown.example.com/")
if err == nil {
t.Fatal("expected error for unknown URL")
}
}
func TestStubClient_CallLog(t *testing.T) {
stub := browser.NewStub()
stub.SetPage("http://example.com/a", "a")
stub.SetPage("http://example.com/b", "b")
stub.GetContent(context.Background(), "http://example.com/a")
stub.GetContent(context.Background(), "http://example.com/b")
log := stub.CallLog()
if len(log) != 2 || log[0] != "http://example.com/a" || log[1] != "http://example.com/b" {
t.Errorf("unexpected call log: %v", log)
}
}

View File

@@ -0,0 +1,219 @@
// Package config loads all service configuration from environment variables.
// Both the runner and backend binaries call config.Load() at startup; each
// uses only the sub-struct relevant to it.
//
// Every field has a documented default so the service starts sensibly without
// any environment configuration (useful for local development).
package config
import (
"os"
"strconv"
"strings"
"time"
)
// PocketBase holds connection settings for the remote PocketBase instance.
type PocketBase struct {
// URL is the base URL of the PocketBase instance, e.g. https://pb.libnovel.cc
URL string
// AdminEmail is the admin account email used for API authentication.
AdminEmail string
// AdminPassword is the admin account password.
AdminPassword string
}
// MinIO holds connection settings for the remote MinIO / S3-compatible store.
type MinIO struct {
// Endpoint is the host:port of the MinIO S3 API, e.g. storage.libnovel.cc:443
Endpoint string
// PublicEndpoint is the browser-visible endpoint used for presigned URLs.
// Falls back to Endpoint when empty.
PublicEndpoint string
// AccessKey is the MinIO access key.
AccessKey string
// SecretKey is the MinIO secret key.
SecretKey string
// UseSSL enables TLS for the internal MinIO connection.
UseSSL bool
// PublicUseSSL enables TLS for presigned URL generation.
PublicUseSSL bool
// BucketChapters is the bucket that holds chapter markdown objects.
BucketChapters string
// BucketAudio is the bucket that holds generated audio MP3 objects.
BucketAudio string
// BucketAvatars is the bucket that holds user avatar images.
BucketAvatars string
// BucketBrowse is the bucket that holds cached browse page snapshots (JSON).
BucketBrowse string
}
// Kokoro holds connection settings for the Kokoro-FastAPI TTS service.
type Kokoro struct {
// URL is the base URL of the Kokoro service, e.g. https://kokoro.libnovel.cc
// An empty string disables TTS generation.
URL string
// DefaultVoice is the voice used when none is specified.
DefaultVoice string
}
// HTTP holds settings for the HTTP server (backend only).
type HTTP struct {
// Addr is the listen address, e.g. ":8080"
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.
type Runner struct {
// PollInterval is how often the runner checks PocketBase for pending tasks.
PollInterval time.Duration
// MaxConcurrentScrape limits simultaneous book-scrape goroutines.
MaxConcurrentScrape int
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
MaxConcurrentAudio int
// WorkerID is a unique identifier for this runner instance.
// Defaults to the system hostname.
WorkerID string
// Workers is the number of chapter-scraping goroutines per book.
Workers int
// Timeout is the per-request HTTP timeout for scraping.
Timeout time.Duration
// MetricsAddr is the listen address for the runner /metrics HTTP endpoint.
// 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
}
// Config is the top-level configuration struct consumed by both binaries.
type Config struct {
PocketBase PocketBase
MinIO MinIO
Kokoro Kokoro
HTTP HTTP
Runner Runner
Meilisearch Meilisearch
Valkey Valkey
// LogLevel is one of "debug", "info", "warn", "error".
LogLevel string
}
// Load reads all configuration from environment variables and returns a
// populated Config. Missing variables fall back to documented defaults.
func Load() Config {
workerID, _ := os.Hostname()
if workerID == "" {
workerID = "runner-default"
}
return Config{
LogLevel: envOr("LOG_LEVEL", "info"),
PocketBase: PocketBase{
URL: envOr("POCKETBASE_URL", "http://localhost:8090"),
AdminEmail: envOr("POCKETBASE_ADMIN_EMAIL", "admin@libnovel.local"),
AdminPassword: envOr("POCKETBASE_ADMIN_PASSWORD", "changeme123"),
},
MinIO: MinIO{
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
UseSSL: envBool("MINIO_USE_SSL", false),
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
},
Kokoro: Kokoro{
URL: envOr("KOKORO_URL", ""),
DefaultVoice: envOr("KOKORO_VOICE", "af_bella"),
},
HTTP: HTTP{
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
},
Runner: Runner{
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
WorkerID: envOr("RUNNER_WORKER_ID", workerID),
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"),
CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0),
},
Meilisearch: Meilisearch{
URL: envOr("MEILI_URL", ""),
APIKey: envOr("MEILI_API_KEY", ""),
},
Valkey: Valkey{
Addr: envOr("VALKEY_ADDR", ""),
},
}
}
// ── helpers ───────────────────────────────────────────────────────────────────
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := os.Getenv(key)
if v == "" {
return fallback
}
return strings.ToLower(v) == "true"
}
func envInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return fallback
}
return n
}
func envDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}

View File

@@ -0,0 +1,127 @@
package config_test
import (
"os"
"testing"
"time"
"github.com/libnovel/backend/internal/config"
)
func TestLoad_Defaults(t *testing.T) {
// Unset all relevant vars so we test pure defaults.
unset := []string{
"LOG_LEVEL",
"POCKETBASE_URL", "POCKETBASE_ADMIN_EMAIL", "POCKETBASE_ADMIN_PASSWORD",
"MINIO_ENDPOINT", "MINIO_PUBLIC_ENDPOINT", "MINIO_ACCESS_KEY", "MINIO_SECRET_KEY",
"MINIO_USE_SSL", "MINIO_PUBLIC_USE_SSL",
"MINIO_BUCKET_CHAPTERS", "MINIO_BUCKET_AUDIO", "MINIO_BUCKET_AVATARS",
"KOKORO_URL", "KOKORO_VOICE",
"BACKEND_HTTP_ADDR",
"RUNNER_POLL_INTERVAL", "RUNNER_MAX_CONCURRENT_SCRAPE", "RUNNER_MAX_CONCURRENT_AUDIO",
"RUNNER_WORKER_ID", "RUNNER_WORKERS", "RUNNER_TIMEOUT",
}
for _, k := range unset {
t.Setenv(k, "")
}
cfg := config.Load()
if cfg.LogLevel != "info" {
t.Errorf("LogLevel: want info, got %q", cfg.LogLevel)
}
if cfg.PocketBase.URL != "http://localhost:8090" {
t.Errorf("PocketBase.URL: want http://localhost:8090, got %q", cfg.PocketBase.URL)
}
if cfg.MinIO.BucketChapters != "libnovel-chapters" {
t.Errorf("MinIO.BucketChapters: want libnovel-chapters, got %q", cfg.MinIO.BucketChapters)
}
if cfg.MinIO.UseSSL != false {
t.Errorf("MinIO.UseSSL: want false, got %v", cfg.MinIO.UseSSL)
}
if cfg.MinIO.PublicUseSSL != true {
t.Errorf("MinIO.PublicUseSSL: want true, got %v", cfg.MinIO.PublicUseSSL)
}
if cfg.Kokoro.DefaultVoice != "af_bella" {
t.Errorf("Kokoro.DefaultVoice: want af_bella, got %q", cfg.Kokoro.DefaultVoice)
}
if cfg.HTTP.Addr != ":8080" {
t.Errorf("HTTP.Addr: want :8080, got %q", cfg.HTTP.Addr)
}
if cfg.Runner.PollInterval != 30*time.Second {
t.Errorf("Runner.PollInterval: want 30s, got %v", cfg.Runner.PollInterval)
}
if cfg.Runner.MaxConcurrentScrape != 1 {
t.Errorf("Runner.MaxConcurrentScrape: want 1, got %d", cfg.Runner.MaxConcurrentScrape)
}
if cfg.Runner.MaxConcurrentAudio != 1 {
t.Errorf("Runner.MaxConcurrentAudio: want 1, got %d", cfg.Runner.MaxConcurrentAudio)
}
}
func TestLoad_EnvOverride(t *testing.T) {
t.Setenv("LOG_LEVEL", "debug")
t.Setenv("POCKETBASE_URL", "https://pb.libnovel.cc")
t.Setenv("MINIO_USE_SSL", "true")
t.Setenv("MINIO_PUBLIC_USE_SSL", "false")
t.Setenv("RUNNER_POLL_INTERVAL", "1m")
t.Setenv("RUNNER_MAX_CONCURRENT_SCRAPE", "5")
t.Setenv("RUNNER_WORKER_ID", "homelab-01")
t.Setenv("BACKEND_HTTP_ADDR", ":9090")
t.Setenv("KOKORO_URL", "https://kokoro.libnovel.cc")
cfg := config.Load()
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel: want debug, got %q", cfg.LogLevel)
}
if cfg.PocketBase.URL != "https://pb.libnovel.cc" {
t.Errorf("PocketBase.URL: want https://pb.libnovel.cc, got %q", cfg.PocketBase.URL)
}
if !cfg.MinIO.UseSSL {
t.Error("MinIO.UseSSL: want true")
}
if cfg.MinIO.PublicUseSSL {
t.Error("MinIO.PublicUseSSL: want false")
}
if cfg.Runner.PollInterval != time.Minute {
t.Errorf("Runner.PollInterval: want 1m, got %v", cfg.Runner.PollInterval)
}
if cfg.Runner.MaxConcurrentScrape != 5 {
t.Errorf("Runner.MaxConcurrentScrape: want 5, got %d", cfg.Runner.MaxConcurrentScrape)
}
if cfg.Runner.WorkerID != "homelab-01" {
t.Errorf("Runner.WorkerID: want homelab-01, got %q", cfg.Runner.WorkerID)
}
if cfg.HTTP.Addr != ":9090" {
t.Errorf("HTTP.Addr: want :9090, got %q", cfg.HTTP.Addr)
}
if cfg.Kokoro.URL != "https://kokoro.libnovel.cc" {
t.Errorf("Kokoro.URL: want https://kokoro.libnovel.cc, got %q", cfg.Kokoro.URL)
}
}
func TestLoad_InvalidInt_FallsToDefault(t *testing.T) {
t.Setenv("RUNNER_MAX_CONCURRENT_SCRAPE", "notanumber")
cfg := config.Load()
if cfg.Runner.MaxConcurrentScrape != 1 {
t.Errorf("want default 1, got %d", cfg.Runner.MaxConcurrentScrape)
}
}
func TestLoad_InvalidDuration_FallsToDefault(t *testing.T) {
t.Setenv("RUNNER_POLL_INTERVAL", "notaduration")
cfg := config.Load()
if cfg.Runner.PollInterval != 30*time.Second {
t.Errorf("want default 30s, got %v", cfg.Runner.PollInterval)
}
}
func TestLoad_WorkerID_FallsToHostname(t *testing.T) {
t.Setenv("RUNNER_WORKER_ID", "")
cfg := config.Load()
host, _ := os.Hostname()
if host != "" && cfg.Runner.WorkerID != host {
t.Errorf("want hostname %q, got %q", host, cfg.Runner.WorkerID)
}
}

View File

@@ -0,0 +1,132 @@
// Package domain contains the core value types shared across all packages
// in this module. It has zero internal imports — only the standard library.
// Every other package imports domain; domain imports nothing from this module.
package domain
import "time"
// ── Book types ────────────────────────────────────────────────────────────────
// BookMeta carries all bibliographic information about a novel.
type BookMeta struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover,omitempty"`
Status string `json:"status,omitempty"`
Genres []string `json:"genres,omitempty"`
Summary string `json:"summary,omitempty"`
TotalChapters int `json:"total_chapters,omitempty"`
SourceURL string `json:"source_url"`
Ranking int `json:"ranking,omitempty"`
Rating float64 `json:"rating,omitempty"`
}
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
type CatalogueEntry struct {
Title string `json:"title"`
URL string `json:"url"`
}
// ChapterRef is a reference to a single chapter returned by chapter-list pages.
type ChapterRef struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Volume int `json:"volume,omitempty"`
}
// Chapter contains the fully-extracted text of a single chapter.
type Chapter struct {
Ref ChapterRef `json:"ref"`
Text string `json:"text"`
}
// RankingItem represents a single entry in the novel ranking list.
type RankingItem struct {
Rank int `json:"rank"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
Cover string `json:"cover,omitempty"`
Status string `json:"status,omitempty"`
Genres []string `json:"genres,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Updated time.Time `json:"updated,omitempty"`
}
// ── Storage record types ──────────────────────────────────────────────────────
// ChapterInfo is a lightweight chapter descriptor stored in the index.
type ChapterInfo struct {
Number int `json:"number"`
Title string `json:"title"`
Date string `json:"date,omitempty"`
}
// ReadingProgress holds a single user's reading position for one book.
type ReadingProgress struct {
Slug string `json:"slug"`
Chapter int `json:"chapter"`
UpdatedAt time.Time `json:"updated_at"`
}
// ── Task record types ─────────────────────────────────────────────────────────
// TaskStatus enumerates the lifecycle states of any task.
type TaskStatus string
const (
TaskStatusPending TaskStatus = "pending"
TaskStatusRunning TaskStatus = "running"
TaskStatusDone TaskStatus = "done"
TaskStatusFailed TaskStatus = "failed"
TaskStatusCancelled TaskStatus = "cancelled"
)
// ScrapeTask represents a book-scraping job stored in PocketBase.
type ScrapeTask struct {
ID string `json:"id"`
Kind string `json:"kind"` // "catalogue" | "book" | "book_range"
TargetURL string `json:"target_url"` // non-empty for single-book tasks
FromChapter int `json:"from_chapter,omitempty"`
ToChapter int `json:"to_chapter,omitempty"`
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`
Errors int `json:"errors"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
type ScrapeResult struct {
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`
Errors int `json:"errors"`
ErrorMessage string `json:"error_message,omitempty"`
}
// AudioTask represents an audio-generation job stored in PocketBase.
type AudioTask struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"` // "slug/chapter/voice"
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// AudioResult is the outcome reported by the runner after finishing an AudioTask.
type AudioResult struct {
ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}

View File

@@ -0,0 +1,104 @@
package domain_test
import (
"encoding/json"
"testing"
"time"
"github.com/libnovel/backend/internal/domain"
)
func TestBookMeta_JSONRoundtrip(t *testing.T) {
orig := domain.BookMeta{
Slug: "a-great-novel",
Title: "A Great Novel",
Author: "Jane Doe",
Cover: "https://example.com/cover.jpg",
Status: "Ongoing",
Genres: []string{"Fantasy", "Action"},
Summary: "A thrilling tale.",
TotalChapters: 120,
SourceURL: "https://novelfire.net/book/a-great-novel",
Ranking: 3,
}
b, err := json.Marshal(orig)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got domain.BookMeta
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Slug != orig.Slug {
t.Errorf("Slug: want %q, got %q", orig.Slug, got.Slug)
}
if got.TotalChapters != orig.TotalChapters {
t.Errorf("TotalChapters: want %d, got %d", orig.TotalChapters, got.TotalChapters)
}
if len(got.Genres) != len(orig.Genres) {
t.Errorf("Genres len: want %d, got %d", len(orig.Genres), len(got.Genres))
}
}
func TestChapterRef_JSONRoundtrip(t *testing.T) {
orig := domain.ChapterRef{Number: 42, Title: "The Battle", URL: "https://example.com/ch-42", Volume: 2}
b, _ := json.Marshal(orig)
var got domain.ChapterRef
json.Unmarshal(b, &got)
if got != orig {
t.Errorf("want %+v, got %+v", orig, got)
}
}
func TestRankingItem_JSONRoundtrip(t *testing.T) {
now := time.Now().Truncate(time.Second)
orig := domain.RankingItem{
Rank: 1,
Slug: "top-novel",
Title: "Top Novel",
SourceURL: "https://novelfire.net/book/top-novel",
Updated: now,
}
b, _ := json.Marshal(orig)
var got domain.RankingItem
json.Unmarshal(b, &got)
if got.Rank != orig.Rank || got.Slug != orig.Slug {
t.Errorf("want %+v, got %+v", orig, got)
}
}
func TestScrapeResult_JSONRoundtrip(t *testing.T) {
orig := domain.ScrapeResult{BooksFound: 10, ChaptersScraped: 200, ChaptersSkipped: 5, Errors: 1, ErrorMessage: "one error"}
b, _ := json.Marshal(orig)
var got domain.ScrapeResult
json.Unmarshal(b, &got)
if got != orig {
t.Errorf("want %+v, got %+v", orig, got)
}
}
func TestAudioResult_JSONRoundtrip(t *testing.T) {
orig := domain.AudioResult{ObjectKey: "audio/slug/1/af_bella.mp3"}
b, _ := json.Marshal(orig)
var got domain.AudioResult
json.Unmarshal(b, &got)
if got != orig {
t.Errorf("want %+v, got %+v", orig, got)
}
}
func TestTaskStatus_Values(t *testing.T) {
cases := []domain.TaskStatus{
domain.TaskStatusPending,
domain.TaskStatusRunning,
domain.TaskStatusDone,
domain.TaskStatusFailed,
domain.TaskStatusCancelled,
}
for _, s := range cases {
if s == "" {
t.Errorf("TaskStatus constant must not be empty")
}
}
}

View File

@@ -0,0 +1,124 @@
// Package httputil provides shared HTTP helpers used by both the runner and
// backend binaries. It has no imports from this module — only the standard
// library — so it is safe to import from anywhere in the dependency graph.
package httputil
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// Client is the minimal interface for making HTTP GET requests.
// *http.Client satisfies this interface.
type Client interface {
Do(req *http.Request) (*http.Response, error)
}
// ErrMaxRetries is returned when RetryGet exhausts all attempts.
var ErrMaxRetries = errors.New("httputil: max retries exceeded")
// errClientError is returned by doGet for 4xx responses; it signals that the
// request should NOT be retried (the client is at fault).
var errClientError = errors.New("httputil: client error")
// RetryGet fetches url using client, retrying on network errors or 5xx
// responses with exponential backoff. It returns the full response body as a
// string on success.
//
// - maxAttempts: total number of attempts (must be >= 1)
// - baseDelay: initial wait before the second attempt; doubles each retry
func RetryGet(ctx context.Context, client Client, url string, maxAttempts int, baseDelay time.Duration) (string, error) {
if maxAttempts < 1 {
maxAttempts = 1
}
delay := baseDelay
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(delay):
}
delay *= 2
}
body, err := doGet(ctx, client, url)
if err == nil {
return body, nil
}
lastErr = err
// Do not retry on context cancellation.
if ctx.Err() != nil {
return "", ctx.Err()
}
// Do not retry on 4xx — the client is at fault.
if errors.Is(err, errClientError) {
return "", err
}
}
return "", fmt.Errorf("%w after %d attempts: %w", ErrMaxRetries, maxAttempts, lastErr)
}
func doGet(ctx context.Context, client Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return "", fmt.Errorf("GET %s: server error %d", url, resp.StatusCode)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("%w: GET %s: client error %d", errClientError, url, resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body %s: %w", url, err)
}
return string(raw), nil
}
// WriteJSON writes v as JSON to w with the given HTTP status code and sets the
// Content-Type header to application/json.
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// WriteError writes a JSON error object {"error": msg} with the given status.
func WriteError(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, map[string]string{"error": msg})
}
// maxBodyBytes is the limit applied by DecodeJSON to prevent unbounded reads.
const maxBodyBytes = 1 << 20 // 1 MiB
// DecodeJSON decodes a JSON request body into v. It enforces a 1 MiB size
// limit and returns a descriptive error on any failure.
func DecodeJSON(r *http.Request, v any) error {
r.Body = http.MaxBytesReader(nil, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return fmt.Errorf("decode JSON body: %w", err)
}
return nil
}

View File

@@ -0,0 +1,181 @@
package httputil_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/libnovel/backend/internal/httputil"
)
// ── RetryGet ──────────────────────────────────────────────────────────────────
func TestRetryGet_ImmediateSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
}))
defer srv.Close()
body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body != "hello" {
t.Errorf("want hello, got %q", body)
}
}
func TestRetryGet_RetriesOn5xx(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
}))
defer srv.Close()
body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if body != "ok" {
t.Errorf("want ok, got %q", body)
}
if calls != 3 {
t.Errorf("want 3 calls, got %d", calls)
}
}
func TestRetryGet_MaxAttemptsExceeded(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond)
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestRetryGet_ContextCancelDuringBackoff(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
// Cancel after first failed attempt hits the backoff wait.
go func() { time.Sleep(5 * time.Millisecond); cancel() }()
_, err := httputil.RetryGet(ctx, srv.Client(), srv.URL, 10, 500*time.Millisecond)
if err == nil {
t.Fatal("expected context cancellation error")
}
}
func TestRetryGet_NoRetryOn4xx(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond)
if err == nil {
t.Fatal("expected error for 404")
}
// 4xx is NOT retried — should be exactly 1 call.
if calls != 1 {
t.Errorf("want 1 call for 4xx, got %d", calls)
}
}
// ── WriteJSON ─────────────────────────────────────────────────────────────────
func TestWriteJSON_SetsHeadersAndStatus(t *testing.T) {
rr := httptest.NewRecorder()
httputil.WriteJSON(rr, http.StatusCreated, map[string]string{"key": "val"})
if rr.Code != http.StatusCreated {
t.Errorf("status: want 201, got %d", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type: want application/json, got %q", ct)
}
var got map[string]string
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
if got["key"] != "val" {
t.Errorf("body key: want val, got %q", got["key"])
}
}
// ── WriteError ────────────────────────────────────────────────────────────────
func TestWriteError_Format(t *testing.T) {
rr := httptest.NewRecorder()
httputil.WriteError(rr, http.StatusBadRequest, "bad input")
if rr.Code != http.StatusBadRequest {
t.Errorf("status: want 400, got %d", rr.Code)
}
var got map[string]string
json.NewDecoder(rr.Body).Decode(&got)
if got["error"] != "bad input" {
t.Errorf("error field: want bad input, got %q", got["error"])
}
}
// ── DecodeJSON ────────────────────────────────────────────────────────────────
func TestDecodeJSON_HappyPath(t *testing.T) {
body := `{"name":"test","value":42}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var payload struct {
Name string `json:"name"`
Value int `json:"value"`
}
if err := httputil.DecodeJSON(req, &payload); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if payload.Name != "test" || payload.Value != 42 {
t.Errorf("unexpected payload: %+v", payload)
}
}
func TestDecodeJSON_UnknownFieldReturnsError(t *testing.T) {
body := `{"name":"test","unknown_field":"boom"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
var payload struct {
Name string `json:"name"`
}
if err := httputil.DecodeJSON(req, &payload); err == nil {
t.Fatal("expected error for unknown field, got nil")
}
}
func TestDecodeJSON_BodyTooLarge(t *testing.T) {
// Build a body > 1 MiB.
big := bytes.Repeat([]byte("a"), 2<<20)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(big))
var payload map[string]any
if err := httputil.DecodeJSON(req, &payload); err == nil {
t.Fatal("expected error for oversized body, got nil")
}
}

View File

@@ -0,0 +1,160 @@
// Package kokoro provides a client for the Kokoro-FastAPI TTS service.
//
// The Kokoro API is an OpenAI-compatible audio speech API that returns a
// download link (X-Download-Path header) instead of streaming audio directly.
// GenerateAudio handles the two-step flow: POST /v1/audio/speech → GET /v1/download/{file}.
package kokoro
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client is the interface for interacting with the Kokoro TTS service.
type Client interface {
// GenerateAudio synthesises text using voice and returns raw MP3 bytes.
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
// ListVoices returns the available voice IDs. Falls back to an empty slice
// on error — callers should treat an empty list as "service unavailable".
ListVoices(ctx context.Context) ([]string, error)
}
// httpClient is the concrete Kokoro HTTP client.
type httpClient struct {
baseURL string
http *http.Client
}
// New returns a Kokoro Client targeting baseURL (e.g. "https://kokoro.example.com").
func New(baseURL string) Client {
return &httpClient{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 10 * time.Minute},
}
}
// GenerateAudio calls POST /v1/audio/speech (return_download_link=true) and then
// downloads the resulting MP3 from GET /v1/download/{filename}.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("kokoro: empty text")
}
if voice == "" {
voice = "af_bella"
}
// ── Step 1: request generation ────────────────────────────────────────────
reqBody, err := json.Marshal(map[string]any{
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "mp3",
"speed": 1.0,
"stream": false,
"return_download_link": true,
})
if err != nil {
return nil, fmt.Errorf("kokoro: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("kokoro: build speech request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kokoro: speech request: %w", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("kokoro: speech returned %d", resp.StatusCode)
}
dlPath := resp.Header.Get("X-Download-Path")
if dlPath == "" {
return nil, fmt.Errorf("kokoro: no X-Download-Path header in response")
}
filename := dlPath
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
filename = dlPath[idx+1:]
}
if filename == "" {
return nil, fmt.Errorf("kokoro: empty filename in X-Download-Path: %q", dlPath)
}
// ── Step 2: download the generated file ───────────────────────────────────
dlURL := c.baseURL + "/v1/download/" + filename
dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, dlURL, nil)
if err != nil {
return nil, fmt.Errorf("kokoro: build download request: %w", err)
}
dlResp, err := c.http.Do(dlReq)
if err != nil {
return nil, fmt.Errorf("kokoro: download request: %w", err)
}
defer dlResp.Body.Close()
if dlResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("kokoro: download returned %d", dlResp.StatusCode)
}
data, err := io.ReadAll(dlResp.Body)
if err != nil {
return nil, fmt.Errorf("kokoro: read download body: %w", err)
}
return data, nil
}
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/v1/audio/voices", nil)
if err != nil {
return nil, fmt.Errorf("kokoro: build voices request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kokoro: voices request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("kokoro: voices returned %d", resp.StatusCode)
}
var result struct {
Voices []string `json:"voices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("kokoro: decode voices response: %w", err)
}
return result.Voices, nil
}
// VoiceSampleKey returns the MinIO object key for a voice sample MP3.
// Key: _voice-samples/{voice}.mp3 (sanitised).
func VoiceSampleKey(voice string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
}

View File

@@ -0,0 +1,291 @@
package kokoro_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/libnovel/backend/internal/kokoro"
)
// ── VoiceSampleKey ────────────────────────────────────────────────────────────
func TestVoiceSampleKey(t *testing.T) {
tests := []struct {
voice string
want string
}{
{"af_bella", "_voice-samples/af_bella.mp3"},
{"am_echo", "_voice-samples/am_echo.mp3"},
{"voice with spaces", "_voice-samples/voice_with_spaces.mp3"},
{"special!@#chars", "_voice-samples/special___chars.mp3"},
{"", "_voice-samples/.mp3"},
}
for _, tt := range tests {
t.Run(tt.voice, func(t *testing.T) {
got := kokoro.VoiceSampleKey(tt.voice)
if got != tt.want {
t.Errorf("VoiceSampleKey(%q) = %q, want %q", tt.voice, got, tt.want)
}
})
}
}
// ── GenerateAudio ─────────────────────────────────────────────────────────────
func TestGenerateAudio_EmptyText(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
c := kokoro.New(srv.URL)
_, err := c.GenerateAudio(context.Background(), "", "af_bella")
if err == nil {
t.Fatal("expected error for empty text, got nil")
}
if !strings.Contains(err.Error(), "empty text") {
t.Errorf("expected 'empty text' in error, got: %v", err)
}
}
func TestGenerateAudio_DefaultVoice(t *testing.T) {
// Tracks that the voice defaults to af_bella when empty.
var capturedBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/speech" {
buf := make([]byte, 512)
n, _ := r.Body.Read(buf)
capturedBody = string(buf[:n])
w.Header().Set("X-Download-Path", "/download/test_file.mp3")
w.WriteHeader(http.StatusOK)
return
}
if strings.HasPrefix(r.URL.Path, "/v1/download/") {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("fake-mp3-data"))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
data, err := c.GenerateAudio(context.Background(), "hello world", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "fake-mp3-data" {
t.Errorf("unexpected data: %q", string(data))
}
if !strings.Contains(capturedBody, `"af_bella"`) {
t.Errorf("expected default voice af_bella in request body, got: %s", capturedBody)
}
}
func TestGenerateAudio_SpeechNon200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/speech" {
w.WriteHeader(http.StatusInternalServerError)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
_, err := c.GenerateAudio(context.Background(), "text", "af_bella")
if err == nil {
t.Fatal("expected error for non-200 speech response")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("expected 500 in error, got: %v", err)
}
}
func TestGenerateAudio_NoDownloadPathHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/speech" {
// No X-Download-Path header
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
_, err := c.GenerateAudio(context.Background(), "text", "af_bella")
if err == nil {
t.Fatal("expected error for missing X-Download-Path")
}
if !strings.Contains(err.Error(), "X-Download-Path") {
t.Errorf("expected X-Download-Path in error, got: %v", err)
}
}
func TestGenerateAudio_DownloadFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/speech" {
w.Header().Set("X-Download-Path", "/v1/download/speech.mp3")
w.WriteHeader(http.StatusOK)
return
}
if strings.HasPrefix(r.URL.Path, "/v1/download/") {
w.WriteHeader(http.StatusNotFound)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
_, err := c.GenerateAudio(context.Background(), "text", "af_bella")
if err == nil {
t.Fatal("expected error for failed download")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("expected 404 in error, got: %v", err)
}
}
func TestGenerateAudio_FullPath(t *testing.T) {
// X-Download-Path with a full path: extract just filename.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/speech" {
w.Header().Set("X-Download-Path", "/some/nested/path/audio_abc123.mp3")
w.WriteHeader(http.StatusOK)
return
}
if r.URL.Path == "/v1/download/audio_abc123.mp3" {
_, _ = w.Write([]byte("audio-bytes"))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
data, err := c.GenerateAudio(context.Background(), "text", "af_bella")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "audio-bytes" {
t.Errorf("unexpected data: %q", string(data))
}
}
func TestGenerateAudio_ContextCancelled(t *testing.T) {
// Server that hangs — context should cancel before we get a response.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Never respond.
select {}
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
c := kokoro.New(srv.URL)
_, err := c.GenerateAudio(ctx, "text", "af_bella")
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
// ── ListVoices ────────────────────────────────────────────────────────────────
func TestListVoices_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/voices" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"voices":["af_bella","am_adam","bf_emma"]}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
voices, err := c.ListVoices(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(voices) != 3 {
t.Errorf("expected 3 voices, got %d: %v", len(voices), voices)
}
if voices[0] != "af_bella" {
t.Errorf("expected first voice to be af_bella, got %q", voices[0])
}
}
func TestListVoices_Non200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
c := kokoro.New(srv.URL)
_, err := c.ListVoices(context.Background())
if err == nil {
t.Fatal("expected error for non-200 response")
}
if !strings.Contains(err.Error(), "503") {
t.Errorf("expected 503 in error, got: %v", err)
}
}
func TestListVoices_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`not-json`))
}))
defer srv.Close()
c := kokoro.New(srv.URL)
_, err := c.ListVoices(context.Background())
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestListVoices_EmptyVoices(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"voices":[]}`))
}))
defer srv.Close()
c := kokoro.New(srv.URL)
voices, err := c.ListVoices(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(voices) != 0 {
t.Errorf("expected 0 voices, got %d", len(voices))
}
}
// ── New ───────────────────────────────────────────────────────────────────────
func TestNew_TrailingSlashStripped(t *testing.T) {
// Verify that a trailing slash on baseURL doesn't produce double-slash paths.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/audio/voices" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"voices":["af_bella"]}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
c := kokoro.New(srv.URL + "/") // trailing slash
voices, err := c.ListVoices(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(voices) == 0 {
t.Error("expected at least one voice")
}
}

View File

@@ -0,0 +1,257 @@
// Package meili provides a thin Meilisearch client for indexing and searching
// locally scraped books.
//
// Index:
// - Name: "books"
// - Primary key: "slug"
// - Searchable attributes: title, author, genres, summary
// - Filterable attributes: status, genres
// - Sortable attributes: rank, rating, total_chapters
//
// The client is intentionally simple: UpsertBook and Search only. All
// Meilisearch-specific details (index management, attribute configuration)
// are handled once in Configure(), called at startup.
package meili
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/libnovel/backend/internal/domain"
"github.com/meilisearch/meilisearch-go"
)
const indexName = "books"
// Client is the interface for Meilisearch operations used by runner and backend.
type Client interface {
// UpsertBook adds or updates a book document in the search index.
UpsertBook(ctx context.Context, book domain.BookMeta) error
// Search returns up to limit books matching query.
Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
// Catalogue queries books with optional filters, sort, and pagination.
// Returns books and the total hit count for pagination.
Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, error)
}
// CatalogueQuery holds parameters for the /api/catalogue endpoint.
type CatalogueQuery struct {
Q string // full-text query (may be empty for browse)
Genre string // genre filter, e.g. "fantasy" or "all"
Status string // status filter, e.g. "ongoing", "completed", or "all"
Sort string // sort field: "popular", "new", "top-rated", "rank", ""
Page int // 1-indexed
Limit int // items per page, default 20
}
// MeiliClient wraps the meilisearch-go SDK.
type MeiliClient struct {
idx meilisearch.IndexManager
}
// New creates a MeiliClient. Call Configure() once at startup to ensure the
// index exists and has the correct attribute settings.
func New(host, apiKey string) *MeiliClient {
cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))
return &MeiliClient{idx: cli.Index(indexName)}
}
// Configure creates the index if absent and sets searchable/filterable
// attributes. It is idempotent — safe to call on every startup.
func Configure(host, apiKey string) error {
cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey))
// Create index with primary key. Returns 202 if exists — ignore.
task, err := cli.CreateIndex(&meilisearch.IndexConfig{
Uid: indexName,
PrimaryKey: "slug",
})
if err != nil {
// 400 "index_already_exists" is not an error here; the SDK returns
// an error with Code "index_already_exists" which we can ignore.
// Any other error is fatal.
if apiErr, ok := err.(*meilisearch.Error); ok && apiErr.MeilisearchApiError.Code == "index_already_exists" {
// already exists — continue
} else {
return fmt.Errorf("meili: create index: %w", err)
}
} else {
_ = task // task is async; we don't wait for it
}
idx := cli.Index(indexName)
searchable := []string{"title", "author", "genres", "summary"}
if _, err := idx.UpdateSearchableAttributes(&searchable); err != nil {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
sortable := []string{"rank", "rating", "total_chapters"}
if _, err := idx.UpdateSortableAttributes(&sortable); err != nil {
return fmt.Errorf("meili: update sortable attributes: %w", err)
}
return nil
}
// bookDoc is the Meilisearch document shape for a book.
type bookDoc struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover"`
Status string `json:"status"`
Genres []string `json:"genres"`
Summary string `json:"summary"`
TotalChapters int `json:"total_chapters"`
SourceURL string `json:"source_url"`
Rank int `json:"rank"`
Rating float64 `json:"rating"`
}
func toDoc(b domain.BookMeta) bookDoc {
return bookDoc{
Slug: b.Slug,
Title: b.Title,
Author: b.Author,
Cover: b.Cover,
Status: b.Status,
Genres: b.Genres,
Summary: b.Summary,
TotalChapters: b.TotalChapters,
SourceURL: b.SourceURL,
Rank: b.Ranking,
Rating: b.Rating,
}
}
func fromDoc(d bookDoc) domain.BookMeta {
return domain.BookMeta{
Slug: d.Slug,
Title: d.Title,
Author: d.Author,
Cover: d.Cover,
Status: d.Status,
Genres: d.Genres,
Summary: d.Summary,
TotalChapters: d.TotalChapters,
SourceURL: d.SourceURL,
Ranking: d.Rank,
Rating: d.Rating,
}
}
// UpsertBook adds or replaces the book document in Meilisearch. The operation
// is fire-and-forget (Meilisearch processes tasks asynchronously).
func (c *MeiliClient) UpsertBook(_ context.Context, book domain.BookMeta) error {
docs := []bookDoc{toDoc(book)}
pk := "slug"
if _, err := c.idx.AddDocuments(docs, &meilisearch.DocumentOptions{PrimaryKey: &pk}); err != nil {
return fmt.Errorf("meili: upsert book %q: %w", book.Slug, err)
}
return nil
}
// Search returns books matching query, up to limit results.
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
if limit <= 0 {
limit = 20
}
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
Limit: int64(limit),
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
}
books := make([]domain.BookMeta, 0, len(res.Hits))
for _, hit := range res.Hits {
// Hit is map[string]json.RawMessage — unmarshal directly into bookDoc.
var doc bookDoc
raw, err := json.Marshal(hit)
if err != nil {
continue
}
if err := json.Unmarshal(raw, &doc); err != nil {
continue
}
books = append(books, fromDoc(doc))
}
return books, nil
}
// Catalogue queries books with optional full-text search, genre/status filters,
// sort order, and pagination. Returns matching books and the total estimate.
func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, error) {
if q.Limit <= 0 {
q.Limit = 20
}
if q.Page <= 0 {
q.Page = 1
}
req := &meilisearch.SearchRequest{
Limit: int64(q.Limit),
Offset: int64((q.Page - 1) * q.Limit),
}
// Build filter
var filters []string
if q.Genre != "" && q.Genre != "all" {
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
}
if q.Status != "" && q.Status != "all" {
filters = append(filters, fmt.Sprintf("status = %q", q.Status))
}
if len(filters) > 0 {
req.Filter = strings.Join(filters, " AND ")
}
// Map UI sort tokens to Meilisearch sort expressions
switch q.Sort {
case "rank":
req.Sort = []string{"rank:asc"}
case "top-rated":
req.Sort = []string{"rating:desc"}
case "new":
req.Sort = []string{"total_chapters:desc"}
// "popular" and "" → relevance (no explicit sort)
}
res, err := c.idx.Search(q.Q, req)
if err != nil {
return nil, 0, fmt.Errorf("meili: catalogue query: %w", err)
}
books := make([]domain.BookMeta, 0, len(res.Hits))
for _, hit := range res.Hits {
var doc bookDoc
raw, err := json.Marshal(hit)
if err != nil {
continue
}
if err := json.Unmarshal(raw, &doc); err != nil {
continue
}
books = append(books, fromDoc(doc))
}
return books, res.EstimatedTotalHits, nil
}
// NoopClient is a no-op Client used when Meilisearch is not configured.
type NoopClient struct{}
func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
return nil, nil
}
func (NoopClient) Catalogue(_ context.Context, _ CatalogueQuery) ([]domain.BookMeta, int64, error) {
return nil, 0, nil
}

View File

@@ -0,0 +1,228 @@
// Package htmlutil provides helper functions for parsing HTML with
// golang.org/x/net/html and extracting values by Selector descriptors.
package htmlutil
import (
"net/url"
"regexp"
"strings"
"github.com/libnovel/backend/internal/scraper"
"golang.org/x/net/html"
)
// ResolveURL returns an absolute URL. If href is already absolute it is
// returned unchanged. Otherwise it is resolved against base.
func ResolveURL(base, href string) string {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
b, err := url.Parse(base)
if err != nil {
return base + href
}
ref, err := url.Parse(href)
if err != nil {
return base + href
}
return b.ResolveReference(ref).String()
}
// ParseHTML parses raw HTML and returns the root node.
func ParseHTML(raw string) (*html.Node, error) {
return html.Parse(strings.NewReader(raw))
}
// selectorMatches reports whether node n matches sel.
func selectorMatches(n *html.Node, sel scraper.Selector) bool {
if n.Type != html.ElementNode {
return false
}
if sel.Tag != "" && n.Data != sel.Tag {
return false
}
if sel.ID != "" {
for _, a := range n.Attr {
if a.Key == "id" && a.Val == sel.ID {
goto checkClass
}
}
return false
}
checkClass:
if sel.Class != "" {
for _, a := range n.Attr {
if a.Key == "class" {
for _, cls := range strings.Fields(a.Val) {
if cls == sel.Class {
goto matched
}
}
}
}
return false
}
matched:
return true
}
// AttrVal returns the value of attribute key from node n.
func AttrVal(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
// TextContent returns the concatenated text content of all descendant text nodes.
func TextContent(n *html.Node) string {
var sb strings.Builder
var walk func(*html.Node)
walk = func(cur *html.Node) {
if cur.Type == html.TextNode {
sb.WriteString(cur.Data)
}
for c := cur.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(sb.String())
}
// FindFirst returns the first node matching sel within root.
func FindFirst(root *html.Node, sel scraper.Selector) *html.Node {
var found *html.Node
var walk func(*html.Node) bool
walk = func(n *html.Node) bool {
if selectorMatches(n, sel) {
found = n
return true
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if walk(c) {
return true
}
}
return false
}
walk(root)
return found
}
// FindAll returns all nodes matching sel within root.
func FindAll(root *html.Node, sel scraper.Selector) []*html.Node {
var results []*html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if selectorMatches(n, sel) {
results = append(results, n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return results
}
// ExtractText extracts a string value from node n using sel.
// If sel.Attr is set the attribute value is returned; otherwise the inner text.
func ExtractText(n *html.Node, sel scraper.Selector) string {
if sel.Attr != "" {
return AttrVal(n, sel.Attr)
}
return TextContent(n)
}
// ExtractFirst locates the first match in root and returns its text/attr value.
func ExtractFirst(root *html.Node, sel scraper.Selector) string {
n := FindFirst(root, sel)
if n == nil {
return ""
}
return ExtractText(n, sel)
}
// ExtractAll locates all matches in root and returns their text/attr values.
func ExtractAll(root *html.Node, sel scraper.Selector) []string {
nodes := FindAll(root, sel)
out := make([]string, 0, len(nodes))
for _, n := range nodes {
if v := ExtractText(n, sel); v != "" {
out = append(out, v)
}
}
return out
}
// NodeToMarkdown converts the children of an HTML node to a plain-text/Markdown
// representation suitable for chapter storage.
func NodeToMarkdown(n *html.Node) string {
var sb strings.Builder
nodeToMD(n, &sb)
out := multiBlankLine.ReplaceAllString(sb.String(), "\n\n")
return strings.TrimSpace(out)
}
var multiBlankLine = regexp.MustCompile(`\n(\s*\n){2,}`)
var blockElements = map[string]bool{
"p": true, "div": true, "br": true, "h1": true, "h2": true,
"h3": true, "h4": true, "h5": true, "h6": true, "li": true,
"blockquote": true, "pre": true, "hr": true,
}
func nodeToMD(n *html.Node, sb *strings.Builder) {
switch n.Type {
case html.TextNode:
sb.WriteString(n.Data)
case html.ElementNode:
tag := n.Data
switch tag {
case "br":
sb.WriteString("\n")
case "hr":
sb.WriteString("\n---\n")
case "h1", "h2", "h3", "h4", "h5", "h6":
level := int(tag[1] - '0')
sb.WriteString("\n" + strings.Repeat("#", level) + " ")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("\n\n")
return
case "p", "div", "blockquote":
sb.WriteString("\n")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("\n")
return
case "em", "i":
sb.WriteString("*")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("*")
return
case "strong", "b":
sb.WriteString("**")
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
sb.WriteString("**")
return
case "script", "style", "noscript":
return // drop
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
nodeToMD(c, sb)
}
if blockElements[tag] {
sb.WriteString("\n")
}
}
}

View File

@@ -0,0 +1,498 @@
// Package novelfire provides a NovelScraper implementation for novelfire.net.
//
// Site structure (as of 2025):
//
// Catalogue : https://novelfire.net/genre-all/sort-new/status-all/all-novel?page=N
// Book page : https://novelfire.net/book/{slug}
// Chapters : https://novelfire.net/book/{slug}/chapters?page=N
// Chapter : https://novelfire.net/book/{slug}/{chapter-slug}
package novelfire
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/libnovel/backend/internal/browser"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/novelfire/htmlutil"
"github.com/libnovel/backend/internal/scraper"
"golang.org/x/net/html"
)
const (
baseURL = "https://novelfire.net"
cataloguePath = "/genre-all/sort-new/status-all/all-novel"
rankingPath = "/genre-all/sort-popular/status-all/all-novel"
)
// Scraper is the novelfire.net implementation of scraper.NovelScraper.
type Scraper struct {
client browser.Client
log *slog.Logger
}
// Compile-time interface check.
var _ scraper.NovelScraper = (*Scraper)(nil)
// New returns a new novelfire Scraper backed by client.
func New(client browser.Client, log *slog.Logger) *Scraper {
if log == nil {
log = slog.Default()
}
return &Scraper{client: client, log: log}
}
// SourceName implements NovelScraper.
func (s *Scraper) SourceName() string { return "novelfire.net" }
// ── CatalogueProvider ─────────────────────────────────────────────────────────
// ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages.
func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
entries := make(chan domain.CatalogueEntry, 64)
errs := make(chan error, 16)
go func() {
defer close(entries)
defer close(errs)
pageURL := baseURL + cataloguePath
page := 1
for pageURL != "" {
select {
case <-ctx.Done():
return
default:
}
s.log.Info("scraping catalogue page", "page", page, "url", pageURL)
raw, err := s.client.GetContent(ctx, pageURL)
if err != nil {
errs <- fmt.Errorf("catalogue page %d: %w", page, err)
return
}
root, err := htmlutil.ParseHTML(raw)
if err != nil {
errs <- fmt.Errorf("catalogue page %d parse: %w", page, err)
return
}
cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true})
if len(cards) == 0 {
s.log.Warn("no novel cards found, stopping pagination", "page", page)
return
}
for _, card := range cards {
linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a", Attr: "href"})
titleNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"})
var title, href string
if linkNode != nil {
href = htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"})
}
if titleNode != nil {
title = strings.TrimSpace(htmlutil.ExtractText(titleNode, scraper.Selector{}))
}
if href == "" || title == "" {
continue
}
bookURL := resolveURL(baseURL, href)
select {
case <-ctx.Done():
return
case entries <- domain.CatalogueEntry{Title: title, URL: bookURL}:
}
}
if !hasNextPageLink(root) {
break
}
nextHref := ""
for _, a := range htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true}) {
if htmlutil.AttrVal(a, "rel") == "next" {
nextHref = htmlutil.AttrVal(a, "href")
break
}
}
if nextHref == "" {
break
}
pageURL = resolveURL(baseURL, nextHref)
page++
}
}()
return entries, errs
}
// ── MetadataProvider ──────────────────────────────────────────────────────────
// ScrapeMetadata fetches and parses book metadata from the book's landing page.
func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) {
s.log.Debug("metadata fetch starting", "url", bookURL)
raw, err := s.client.GetContent(ctx, bookURL)
if err != nil {
return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err)
}
root, err := htmlutil.ParseHTML(raw)
if err != nil {
return domain.BookMeta{}, fmt.Errorf("metadata parse %s: %w", bookURL, err)
}
title := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "h1", Class: "novel-title"})
author := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "author"})
var cover string
if fig := htmlutil.FindFirst(root, scraper.Selector{Tag: "figure", Class: "cover"}); fig != nil {
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"})
if cover != "" && !strings.HasPrefix(cover, "http") {
cover = baseURL + cover
}
}
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
var genres []string
if genresNode != nil {
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true})
}
summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"})
totalStr := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "chapter-count"})
totalChapters := parseChapterCount(totalStr)
slug := slugFromURL(bookURL)
meta := domain.BookMeta{
Slug: slug,
Title: title,
Author: author,
Cover: cover,
Status: status,
Genres: genres,
Summary: summary,
TotalChapters: totalChapters,
SourceURL: bookURL,
}
s.log.Debug("metadata parsed", "slug", meta.Slug, "title", meta.Title)
return meta, nil
}
// ── ChapterListProvider ───────────────────────────────────────────────────────
// ScrapeChapterList returns all chapter references for a book, ordered ascending.
func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error) {
var refs []domain.ChapterRef
baseChapterURL := strings.TrimRight(bookURL, "/") + "/chapters"
page := 1
for {
select {
case <-ctx.Done():
return refs, ctx.Err()
default:
}
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
raw, err := s.client.GetContent(ctx, pageURL)
if err != nil {
return refs, fmt.Errorf("chapter list page %d: %w", page, err)
}
root, err := htmlutil.ParseHTML(raw)
if err != nil {
return refs, fmt.Errorf("chapter list page %d parse: %w", page, err)
}
chapterList := htmlutil.FindFirst(root, scraper.Selector{Class: "chapter-list"})
if chapterList == nil {
s.log.Debug("chapter list container not found, stopping pagination", "page", page)
break
}
items := htmlutil.FindAll(chapterList, scraper.Selector{Tag: "li"})
if len(items) == 0 {
break
}
for _, item := range items {
linkNode := htmlutil.FindFirst(item, scraper.Selector{Tag: "a"})
if linkNode == nil {
continue
}
href := htmlutil.ExtractText(linkNode, scraper.Selector{Attr: "href"})
chTitle := htmlutil.ExtractText(linkNode, scraper.Selector{})
if href == "" {
continue
}
chURL := resolveURL(baseURL, href)
num := chapterNumberFromURL(chURL)
if num <= 0 {
num = len(refs) + 1
s.log.Warn("chapter number not parseable from URL, falling back to position",
"url", chURL, "position", num)
}
refs = append(refs, domain.ChapterRef{
Number: num,
Title: strings.TrimSpace(chTitle),
URL: chURL,
})
}
page++
}
return refs, nil
}
// ── ChapterTextProvider ───────────────────────────────────────────────────────
// ScrapeChapterText fetches and parses a single chapter page.
func (s *Scraper) ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
s.log.Debug("chapter text fetch starting", "chapter", ref.Number, "url", ref.URL)
raw, err := retryGet(ctx, s.log, s.client, ref.URL, 9, 6*time.Second)
if err != nil {
return domain.Chapter{}, fmt.Errorf("chapter %d fetch: %w", ref.Number, err)
}
root, err := htmlutil.ParseHTML(raw)
if err != nil {
return domain.Chapter{}, fmt.Errorf("chapter %d parse: %w", ref.Number, err)
}
container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"})
if container == nil {
return domain.Chapter{}, fmt.Errorf("chapter %d: #content container not found in %s", ref.Number, ref.URL)
}
text := htmlutil.NodeToMarkdown(container)
s.log.Debug("chapter text parsed", "chapter", ref.Number, "text_bytes", len(text))
return domain.Chapter{Ref: ref, Text: text}, nil
}
// ── RankingProvider ───────────────────────────────────────────────────────────
// ScrapeRanking pages through up to maxPages pages of the popular-novels listing.
// maxPages <= 0 means all pages. The caller decides whether to persist items.
func (s *Scraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) {
entries := make(chan domain.BookMeta, 32)
errs := make(chan error, 16)
go func() {
defer close(entries)
defer close(errs)
rank := 1
for page := 1; maxPages <= 0 || page <= maxPages; page++ {
select {
case <-ctx.Done():
return
default:
}
pageURL := fmt.Sprintf("%s%s?page=%d", baseURL, rankingPath, page)
s.log.Info("scraping popular ranking page", "page", page, "url", pageURL)
raw, err := s.client.GetContent(ctx, pageURL)
if err != nil {
errs <- fmt.Errorf("ranking page %d: %w", page, err)
return
}
root, err := htmlutil.ParseHTML(raw)
if err != nil {
errs <- fmt.Errorf("ranking page %d parse: %w", page, err)
return
}
cards := htmlutil.FindAll(root, scraper.Selector{Tag: "li", Class: "novel-item", Multiple: true})
if len(cards) == 0 {
break
}
for _, card := range cards {
linkNode := htmlutil.FindFirst(card, scraper.Selector{Tag: "a"})
if linkNode == nil {
continue
}
href := htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "href"})
bookURL := resolveURL(baseURL, href)
if bookURL == "" {
continue
}
title := strings.TrimSpace(htmlutil.ExtractFirst(card, scraper.Selector{Tag: "h4", Class: "novel-title"}))
if title == "" {
title = strings.TrimSpace(htmlutil.ExtractText(linkNode, scraper.Selector{Tag: "a", Attr: "title"}))
}
if title == "" {
continue
}
var cover string
if fig := htmlutil.FindFirst(card, scraper.Selector{Tag: "figure", Class: "novel-cover"}); fig != nil {
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "data-src"})
if cover == "" {
cover = htmlutil.ExtractFirst(fig, scraper.Selector{Tag: "img", Attr: "src"})
}
if strings.HasPrefix(cover, "data:") {
cover = ""
}
if cover != "" && !strings.HasPrefix(cover, "http") {
cover = baseURL + cover
}
}
meta := domain.BookMeta{
Slug: slugFromURL(bookURL),
Title: title,
Cover: cover,
SourceURL: bookURL,
Ranking: rank,
}
rank++
select {
case <-ctx.Done():
return
case entries <- meta:
}
}
if !hasNextPageLink(root) {
break
}
}
}()
return entries, errs
}
// ── helpers ───────────────────────────────────────────────────────────────────
func resolveURL(base, href string) string { return htmlutil.ResolveURL(base, href) }
func hasNextPageLink(root *html.Node) bool {
links := htmlutil.FindAll(root, scraper.Selector{Tag: "a", Multiple: true})
for _, a := range links {
for _, attr := range a.Attr {
if attr.Key == "rel" && attr.Val == "next" {
return true
}
}
}
return false
}
func slugFromURL(bookURL string) string {
u, err := url.Parse(bookURL)
if err != nil {
return bookURL
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) >= 2 && parts[0] == "book" {
return parts[1]
}
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
func parseChapterCount(s string) int {
s = strings.ReplaceAll(s, ",", "")
fields := strings.Fields(s)
if len(fields) == 0 {
return 0
}
n, _ := strconv.Atoi(fields[0])
return n
}
func chapterNumberFromURL(chapterURL string) int {
u, err := url.Parse(chapterURL)
if err != nil {
return 0
}
seg := path.Base(u.Path)
seg = strings.TrimPrefix(seg, "chapter-")
seg = strings.TrimPrefix(seg, "chap-")
seg = strings.TrimPrefix(seg, "ch-")
digits := strings.FieldsFunc(seg, func(r rune) bool {
return r < '0' || r > '9'
})
if len(digits) == 0 {
return 0
}
n, _ := strconv.Atoi(digits[0])
return n
}
// retryGet calls client.GetContent up to maxAttempts times with exponential backoff.
// If the server returns 429 (ErrRateLimit), the suggested Retry-After delay is used
// instead of the geometric backoff delay.
func retryGet(
ctx context.Context,
log *slog.Logger,
client browser.Client,
pageURL string,
maxAttempts int,
baseDelay time.Duration,
) (string, error) {
var lastErr error
delay := baseDelay
for attempt := 1; attempt <= maxAttempts; attempt++ {
raw, err := client.GetContent(ctx, pageURL)
if err == nil {
return raw, nil
}
lastErr = err
if ctx.Err() != nil {
return "", err
}
if attempt < maxAttempts {
// If the server is rate-limiting us, honour its Retry-After delay.
waitFor := delay
var rlErr *browser.RateLimitError
if errors.As(err, &rlErr) {
waitFor = rlErr.RetryAfter
if log != nil {
log.Warn("rate limited, backing off",
"url", pageURL, "attempt", attempt, "retry_in", waitFor)
}
} else {
if log != nil {
log.Warn("fetch failed, retrying",
"url", pageURL, "attempt", attempt, "retry_in", delay, "err", err)
}
delay *= 2
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(waitFor):
}
}
}
return "", lastErr
}

View File

@@ -0,0 +1,129 @@
package novelfire
import (
"context"
"testing"
)
func TestSlugFromURL(t *testing.T) {
cases := []struct {
url string
want string
}{
{"https://novelfire.net/book/shadow-slave", "shadow-slave"},
{"https://novelfire.net/book/a-dragon-against-the-whole-world", "a-dragon-against-the-whole-world"},
{"https://novelfire.net/book/foo/chapter-1", "foo"},
{"https://novelfire.net/", ""},
{"not-a-url", "not-a-url"},
}
for _, c := range cases {
got := slugFromURL(c.url)
if got != c.want {
t.Errorf("slugFromURL(%q) = %q, want %q", c.url, got, c.want)
}
}
}
func TestChapterNumberFromURL(t *testing.T) {
cases := []struct {
url string
want int
}{
{"https://novelfire.net/book/shadow-slave/chapter-42", 42},
{"https://novelfire.net/book/shadow-slave/chapter-1000", 1000},
{"https://novelfire.net/book/shadow-slave/chap-7", 7},
{"https://novelfire.net/book/shadow-slave/ch-3", 3},
{"https://novelfire.net/book/shadow-slave/42", 42},
{"https://novelfire.net/book/shadow-slave/no-number-here", 0},
{"not-a-url", 0},
}
for _, c := range cases {
got := chapterNumberFromURL(c.url)
if got != c.want {
t.Errorf("chapterNumberFromURL(%q) = %d, want %d", c.url, got, c.want)
}
}
}
func TestParseChapterCount(t *testing.T) {
cases := []struct {
in string
want int
}{
{"123 Chapters", 123},
{"1,234 Chapters", 1234},
{"0", 0},
{"", 0},
{"500", 500},
}
for _, c := range cases {
got := parseChapterCount(c.in)
if got != c.want {
t.Errorf("parseChapterCount(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestRetryGet_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
stub := newStubClient()
stub.setError("https://example.com/page", context.Canceled)
_, err := retryGet(ctx, nil, stub, "https://example.com/page", 3, 0)
if err == nil {
t.Fatal("expected error on cancelled context")
}
}
func TestRetryGet_EventualSuccess(t *testing.T) {
stub := newStubClient()
calls := 0
stub.setFn("https://example.com/page", func() (string, error) {
calls++
if calls < 3 {
return "", context.DeadlineExceeded
}
return "<html>ok</html>", nil
})
got, err := retryGet(context.Background(), nil, stub, "https://example.com/page", 5, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "<html>ok</html>" {
t.Errorf("got %q, want html", got)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
// ── minimal stub client for tests ─────────────────────────────────────────────
type stubClient struct {
errors map[string]error
fns map[string]func() (string, error)
}
func newStubClient() *stubClient {
return &stubClient{
errors: make(map[string]error),
fns: make(map[string]func() (string, error)),
}
}
func (s *stubClient) setError(u string, err error) { s.errors[u] = err }
func (s *stubClient) setFn(u string, fn func() (string, error)) { s.fns[u] = fn }
func (s *stubClient) GetContent(_ context.Context, pageURL string) (string, error) {
if fn, ok := s.fns[pageURL]; ok {
return fn()
}
if err, ok := s.errors[pageURL]; ok {
return "", err
}
return "", context.DeadlineExceeded
}

View File

@@ -0,0 +1,222 @@
// Package orchestrator coordinates metadata extraction, chapter-list fetching,
// and parallel chapter scraping for a single book.
//
// Design:
// - 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.
// - 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
import (
"context"
"fmt"
"log/slog"
"runtime"
"sync"
"sync/atomic"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/scraper"
)
// Config holds tunable parameters for the orchestrator.
type Config struct {
// Workers is the number of goroutines used to scrape chapters in parallel.
// Defaults to runtime.NumCPU() when 0.
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.
type Orchestrator struct {
novel scraper.NovelScraper
store bookstore.BookWriter
log *slog.Logger
workers int
postMetadata func(ctx context.Context, meta domain.BookMeta)
}
// New returns a new Orchestrator.
func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log *slog.Logger) *Orchestrator {
if log == nil {
log = slog.Default()
}
workers := cfg.Workers
if workers <= 0 {
workers = runtime.NumCPU()
}
return &Orchestrator{
novel: novel,
store: store,
log: log,
workers: workers,
postMetadata: cfg.PostMetadata,
}
}
// RunBook scrapes a single book described by task. It handles:
// 1. Metadata scrape + write
// 2. Chapter list scrape + write
// 3. Parallel chapter text scrape + write (worker pool)
//
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
// if the run failed at the metadata or chapter-list level.
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
o.log.Info("orchestrator: RunBook starting",
"task_id", task.ID,
"kind", task.Kind,
"url", task.TargetURL,
"workers", o.workers,
)
var result domain.ScrapeResult
if task.TargetURL == "" {
result.ErrorMessage = "task has no target URL"
return result
}
// ── Step 1: Metadata ──────────────────────────────────────────────────────
meta, err := o.novel.ScrapeMetadata(ctx, task.TargetURL)
if err != nil {
o.log.Error("metadata scrape failed", "url", task.TargetURL, "err", err)
result.ErrorMessage = fmt.Sprintf("metadata: %v", err)
result.Errors++
return result
}
if err := o.store.WriteMetadata(ctx, meta); err != nil {
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
// non-fatal: continue to chapters
result.Errors++
} else {
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)
// ── Step 2: Chapter list ──────────────────────────────────────────────────
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL)
if err != nil {
o.log.Error("chapter list scrape failed", "slug", meta.Slug, "err", err)
result.ErrorMessage = fmt.Sprintf("chapter list: %v", err)
result.Errors++
return result
}
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
// Persist chapter refs (without text) so the index exists early.
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {
o.log.Warn("chapter refs write failed", "slug", meta.Slug, "err", wErr)
}
// ── Step 3: Chapter texts (worker pool) ───────────────────────────────────
type chapterJob struct {
slug string
ref domain.ChapterRef
total int // total chapters to scrape (for progress logging)
}
work := make(chan chapterJob, o.workers*4)
var scraped, skipped, errors atomic.Int64
var wg sync.WaitGroup
for i := 0; i < o.workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range work {
select {
case <-ctx.Done():
return
default:
}
if o.store.ChapterExists(ctx, job.slug, job.ref) {
o.log.Debug("chapter already exists, skipping",
"slug", job.slug, "chapter", job.ref.Number)
skipped.Add(1)
continue
}
ch, err := o.novel.ScrapeChapterText(ctx, job.ref)
if err != nil {
o.log.Error("chapter scrape failed",
"slug", job.slug, "chapter", job.ref.Number, "err", err)
errors.Add(1)
continue
}
if err := o.store.WriteChapter(ctx, job.slug, ch); err != nil {
o.log.Error("chapter write failed",
"slug", job.slug, "chapter", job.ref.Number, "err", err)
errors.Add(1)
continue
}
n := scraped.Add(1)
// Log a progress summary every 25 chapters scraped.
if n%25 == 0 {
o.log.Info("scraping chapters",
"slug", job.slug, "scraped", n, "total", job.total)
}
}
}(i)
}
// Count how many chapters will actually be enqueued (for progress logging).
toScrape := 0
for _, ref := range refs {
if task.FromChapter > 0 && ref.Number < task.FromChapter {
continue
}
if task.ToChapter > 0 && ref.Number > task.ToChapter {
continue
}
toScrape++
}
// Enqueue chapter jobs respecting the optional range filter from the task.
for _, ref := range refs {
if task.FromChapter > 0 && ref.Number < task.FromChapter {
skipped.Add(1)
continue
}
if task.ToChapter > 0 && ref.Number > task.ToChapter {
skipped.Add(1)
continue
}
select {
case <-ctx.Done():
goto drain
case work <- chapterJob{slug: meta.Slug, ref: ref, total: toScrape}:
}
}
drain:
close(work)
wg.Wait()
result.ChaptersScraped = int(scraped.Load())
result.ChaptersSkipped = int(skipped.Load())
result.Errors += int(errors.Load())
o.log.Info("book scrape finished",
"slug", meta.Slug,
"scraped", result.ChaptersScraped,
"skipped", result.ChaptersSkipped,
"errors", result.Errors,
)
return result
}

View File

@@ -0,0 +1,210 @@
package orchestrator
import (
"context"
"errors"
"sync"
"testing"
"github.com/libnovel/backend/internal/domain"
)
// ── stubs ─────────────────────────────────────────────────────────────────────
type stubScraper struct {
meta domain.BookMeta
metaErr error
refs []domain.ChapterRef
refsErr error
chapters map[int]domain.Chapter
chapErr map[int]error
}
func (s *stubScraper) SourceName() string { return "stub" }
func (s *stubScraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
ch := make(chan domain.CatalogueEntry)
errs := make(chan error)
close(ch)
close(errs)
return ch, errs
}
func (s *stubScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) {
return s.meta, s.metaErr
}
func (s *stubScraper) ScrapeChapterList(_ context.Context, _ string) ([]domain.ChapterRef, error) {
return s.refs, s.refsErr
}
func (s *stubScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
if s.chapErr != nil {
if err, ok := s.chapErr[ref.Number]; ok {
return domain.Chapter{}, err
}
}
if s.chapters != nil {
if ch, ok := s.chapters[ref.Number]; ok {
return ch, nil
}
}
return domain.Chapter{Ref: ref, Text: "text"}, nil
}
func (s *stubScraper) ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error) {
ch := make(chan domain.BookMeta)
errs := make(chan error)
close(ch)
close(errs)
return ch, errs
}
type stubStore struct {
mu sync.Mutex
metaWritten []domain.BookMeta
chaptersWritten []domain.Chapter
existing map[string]bool // "slug:N" → exists
writeMetaErr error
}
func (s *stubStore) WriteMetadata(_ context.Context, meta domain.BookMeta) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.writeMetaErr != nil {
return s.writeMetaErr
}
s.metaWritten = append(s.metaWritten, meta)
return nil
}
func (s *stubStore) WriteChapter(_ context.Context, slug string, ch domain.Chapter) error {
s.mu.Lock()
defer s.mu.Unlock()
s.chaptersWritten = append(s.chaptersWritten, ch)
return nil
}
func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error {
return nil
}
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
s.mu.Lock()
defer s.mu.Unlock()
key := slug + ":" + string(rune('0'+ref.Number))
return s.existing[key]
}
// ── tests ──────────────────────────────────────────────────────────────────────
func TestRunBook_HappyPath(t *testing.T) {
sc := &stubScraper{
meta: domain.BookMeta{Slug: "test-book", Title: "Test Book", SourceURL: "https://example.com/book/test-book"},
refs: []domain.ChapterRef{
{Number: 1, Title: "Ch 1", URL: "https://example.com/book/test-book/chapter-1"},
{Number: 2, Title: "Ch 2", URL: "https://example.com/book/test-book/chapter-2"},
{Number: 3, Title: "Ch 3", URL: "https://example.com/book/test-book/chapter-3"},
},
}
st := &stubStore{}
o := New(Config{Workers: 2}, sc, st, nil)
task := domain.ScrapeTask{
ID: "t1",
Kind: "book",
TargetURL: "https://example.com/book/test-book",
}
result := o.RunBook(context.Background(), task)
if result.ErrorMessage != "" {
t.Fatalf("unexpected error: %s", result.ErrorMessage)
}
if result.BooksFound != 1 {
t.Errorf("BooksFound = %d, want 1", result.BooksFound)
}
if result.ChaptersScraped != 3 {
t.Errorf("ChaptersScraped = %d, want 3", result.ChaptersScraped)
}
}
func TestRunBook_MetadataError(t *testing.T) {
sc := &stubScraper{metaErr: errors.New("404 not found")}
st := &stubStore{}
o := New(Config{Workers: 1}, sc, st, nil)
result := o.RunBook(context.Background(), domain.ScrapeTask{
ID: "t2",
TargetURL: "https://example.com/book/missing",
})
if result.ErrorMessage == "" {
t.Fatal("expected ErrorMessage to be set")
}
if result.Errors != 1 {
t.Errorf("Errors = %d, want 1", result.Errors)
}
}
func TestRunBook_ChapterRange(t *testing.T) {
sc := &stubScraper{
meta: domain.BookMeta{Slug: "range-book", SourceURL: "https://example.com/book/range-book"},
refs: func() []domain.ChapterRef {
var refs []domain.ChapterRef
for i := 1; i <= 10; i++ {
refs = append(refs, domain.ChapterRef{Number: i, URL: "https://example.com/book/range-book/chapter-" + string(rune('0'+i))})
}
return refs
}(),
}
st := &stubStore{}
o := New(Config{Workers: 2}, sc, st, nil)
result := o.RunBook(context.Background(), domain.ScrapeTask{
ID: "t3",
TargetURL: "https://example.com/book/range-book",
FromChapter: 3,
ToChapter: 7,
})
if result.ErrorMessage != "" {
t.Fatalf("unexpected error: %s", result.ErrorMessage)
}
// chapters 37 = 5 scraped, chapters 1-2 and 8-10 = 5 skipped
if result.ChaptersScraped != 5 {
t.Errorf("ChaptersScraped = %d, want 5", result.ChaptersScraped)
}
if result.ChaptersSkipped != 5 {
t.Errorf("ChaptersSkipped = %d, want 5", result.ChaptersSkipped)
}
}
func TestRunBook_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
sc := &stubScraper{
meta: domain.BookMeta{Slug: "ctx-book", SourceURL: "https://example.com/book/ctx-book"},
refs: []domain.ChapterRef{
{Number: 1, URL: "https://example.com/book/ctx-book/chapter-1"},
},
}
st := &stubStore{}
o := New(Config{Workers: 1}, sc, st, nil)
// Should not panic; result may have errors or zero chapters.
result := o.RunBook(ctx, domain.ScrapeTask{
ID: "t4",
TargetURL: "https://example.com/book/ctx-book",
})
_ = result
}
func TestRunBook_EmptyTargetURL(t *testing.T) {
o := New(Config{Workers: 1}, &stubScraper{}, &stubStore{}, nil)
result := o.RunBook(context.Background(), domain.ScrapeTask{ID: "t5"})
if result.ErrorMessage == "" {
t.Fatal("expected ErrorMessage for empty target URL")
}
}

View File

@@ -0,0 +1,96 @@
// Package presigncache provides a Valkey (Redis-compatible) backed cache for
// MinIO presigned URLs. The backend generates presigned URLs and stores them
// here with a TTL; subsequent requests for the same key return the cached URL
// without re-contacting MinIO.
//
// Design:
// - Cache is intentionally best-effort: Get returns ("", false, nil) on any
// Valkey error, so callers always have a fallback path to regenerate.
// - Set silently drops errors — a miss on the next request is acceptable.
// - TTL should be set shorter than the actual presigned URL lifetime so that
// cached URLs are always valid when served. Recommended: 55 minutes for a
// 1-hour presigned URL.
package presigncache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// Cache is the interface for presign URL caching.
// Implementations must be safe for concurrent use.
type Cache interface {
// Get returns the cached URL for key. ok is false on cache miss or error.
Get(ctx context.Context, key string) (url string, ok bool, err error)
// Set stores url under key with the given TTL.
Set(ctx context.Context, key, url string, ttl time.Duration) error
// Delete removes key from the cache.
Delete(ctx context.Context, key string) error
}
// ValkeyCache is a Cache backed by Valkey / Redis via go-redis.
type ValkeyCache struct {
rdb *redis.Client
}
// New creates a ValkeyCache connecting to addr (e.g. "valkey:6379").
// The connection is not established until the first command; use Ping to
// verify connectivity at startup.
func New(addr string) *ValkeyCache {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
DialTimeout: 2 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
})
return &ValkeyCache{rdb: rdb}
}
// Ping checks connectivity. Call once at startup.
func (c *ValkeyCache) Ping(ctx context.Context) error {
if err := c.rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("presigncache: ping valkey: %w", err)
}
return nil
}
// Get returns (url, true, nil) on hit, ("", false, nil) on miss, and
// ("", false, err) only on unexpected errors (not redis.Nil).
func (c *ValkeyCache) Get(ctx context.Context, key string) (string, bool, error) {
val, err := c.rdb.Get(ctx, key).Result()
if err == redis.Nil {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("presigncache: get %q: %w", key, err)
}
return val, true, nil
}
// Set stores url under key with ttl. Errors are returned but are non-fatal
// for callers — a Set failure means the next request will miss and regenerate.
func (c *ValkeyCache) Set(ctx context.Context, key, url string, ttl time.Duration) error {
if err := c.rdb.Set(ctx, key, url, ttl).Err(); err != nil {
return fmt.Errorf("presigncache: set %q: %w", key, err)
}
return nil
}
// Delete removes key from the cache. It is not an error if the key does not exist.
func (c *ValkeyCache) Delete(ctx context.Context, key string) error {
if err := c.rdb.Del(ctx, key).Err(); err != nil {
return fmt.Errorf("presigncache: delete %q: %w", key, err)
}
return nil
}
// NoopCache is a no-op Cache that always returns a miss. Used when Valkey is
// not configured (e.g. local development without Docker).
type NoopCache struct{}
func (NoopCache) Get(_ context.Context, _ string) (string, bool, error) { return "", false, nil }
func (NoopCache) Set(_ context.Context, _, _ string, _ time.Duration) error { return nil }
func (NoopCache) Delete(_ context.Context, _ string) error { return nil }

View File

@@ -0,0 +1,176 @@
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
}

View File

@@ -0,0 +1,177 @@
package runner
// catalogue_refresh.go — independent loop that walks the full novelfire.net
// catalogue, scrapes per-book metadata, downloads cover images to MinIO, and
// indexes every book in Meilisearch.
//
// Design:
// - Runs on its own ticker (CatalogueRefreshInterval, default 24h) inside Run().
// - Also fires once on startup.
// - ScrapeCatalogue streams CatalogueEntry values over a channel — we iterate
// and call ScrapeMetadata for each entry.
// - Per-request random jitter (13s) prevents hammering novelfire.net.
// - Cover images are fetched from the URL embedded in BookMeta.Cover and
// stored in MinIO (browse bucket, key: covers/{slug}.jpg).
// - WriteMetadata + UpsertBook are called for every successfully scraped book.
// - Errors for individual books are logged and skipped; the loop continues.
// - The cover URL stored in BookMeta.Cover is rewritten to the internal proxy
// path (/api/cover/novelfire.net/{slug}) so the UI always fetches via the
// backend, which will serve from MinIO.
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"time"
)
// runCatalogueRefresh performs one full catalogue walk: scrapes metadata for
// every book on novelfire.net, downloads covers to MinIO, and upserts to
// Meilisearch. Errors for individual books are logged and skipped.
func (r *Runner) runCatalogueRefresh(ctx context.Context) {
if r.deps.Novel == nil {
r.deps.Log.Warn("runner: catalogue refresh skipped — Novel scraper not configured")
return
}
if r.deps.BookWriter == nil {
r.deps.Log.Warn("runner: catalogue refresh skipped — BookWriter not configured")
return
}
log := r.deps.Log.With("op", "catalogue_refresh")
log.Info("runner: catalogue refresh starting")
entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx)
ok, skipped, errCount := 0, 0, 0
for entry := range entries {
if ctx.Err() != nil {
break
}
// Random jitter between books to avoid rate-limiting.
jitter := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
select {
case <-ctx.Done():
break
case <-time.After(jitter):
}
meta, err := r.deps.Novel.ScrapeMetadata(ctx, entry.URL)
if err != nil {
log.Warn("runner: catalogue refresh: metadata scrape failed",
"url", entry.URL, "err", err)
errCount++
continue
}
// Rewrite cover URL to backend proxy path so UI never hits CDN directly.
originalCover := meta.Cover
meta.Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", meta.Slug)
// Persist to PocketBase.
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
log.Warn("runner: catalogue refresh: WriteMetadata failed",
"slug", meta.Slug, "err", err)
errCount++
continue
}
// Index in Meilisearch.
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
log.Warn("runner: catalogue refresh: UpsertBook failed",
"slug", meta.Slug, "err", err)
// non-fatal — continue
}
// Download and store cover image in MinIO if we have a cover URL
// and a CoverStore is wired in.
if r.deps.CoverStore != nil && originalCover != "" {
if !r.deps.CoverStore.CoverExists(ctx, meta.Slug) {
if err := r.downloadCover(ctx, meta.Slug, originalCover); err != nil {
log.Warn("runner: catalogue refresh: cover download failed",
"slug", meta.Slug, "url", originalCover, "err", err)
// non-fatal
}
}
}
ok++
if ok%100 == 0 {
log.Info("runner: catalogue refresh progress",
"scraped", ok, "errors", errCount)
}
}
if err := <-errCh; err != nil {
log.Warn("runner: catalogue refresh: catalogue stream error", "err", err)
}
log.Info("runner: catalogue refresh finished",
"ok", ok, "skipped", skipped, "errors", errCount)
}
// downloadCover fetches the cover image from coverURL and stores it in MinIO
// under covers/{slug}.jpg. It retries up to 3 times with exponential backoff
// on transient errors (5xx, network failures).
func (r *Runner) downloadCover(ctx context.Context, slug, coverURL string) error {
const maxRetries = 3
delay := 2 * time.Second
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if ctx.Err() != nil {
return ctx.Err()
}
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay *= 2
}
data, err := fetchCoverBytes(ctx, coverURL)
if err != nil {
lastErr = err
continue
}
if err := r.deps.CoverStore.PutCover(ctx, slug, data, ""); err != nil {
return fmt.Errorf("put cover: %w", err)
}
return nil
}
return fmt.Errorf("download cover after %d retries: %w", maxRetries, lastErr)
}
// fetchCoverBytes performs a single HTTP GET for coverURL and returns the body.
func fetchCoverBytes(ctx context.Context, coverURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, coverURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-runner/2)")
req.Header.Set("Referer", "https://novelfire.net/")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("upstream %d for %s", resp.StatusCode, coverURL)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, coverURL)
}
return io.ReadAll(io.LimitReader(resp.Body, 5<<20)) // 5 MiB cap
}

View File

@@ -0,0 +1,21 @@
package runner
import (
"regexp"
"strings"
)
// stripMarkdown removes common markdown syntax from src, returning plain text
// suitable for TTS. Mirrors the helper in the scraper's server package.
func stripMarkdown(src string) string {
src = regexp.MustCompile(`(?m)^#{1,6}\s+`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\*{1,3}|_{1,3}`).ReplaceAllString(src, "")
src = regexp.MustCompile("(?s)```.*?```").ReplaceAllString(src, "")
src = regexp.MustCompile("`[^`]*`").ReplaceAllString(src, "")
src = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(src, "$1")
src = regexp.MustCompile(`!\[[^\]]*\]\([^)]+\)`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^>\s?`).ReplaceAllString(src, "")
src = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`).ReplaceAllString(src, "")
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
return strings.TrimSpace(src)
}

View File

@@ -0,0 +1,92 @@
package runner
// metrics.go — lightweight HTTP metrics endpoint for the runner.
//
// GET /metrics returns a JSON document with live task counters and uptime.
// No external dependency (no Prometheus); plain net/http only.
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"time"
)
// metricsServer serves GET /metrics for the runner process.
type metricsServer struct {
addr string
r *Runner
log *slog.Logger
}
func newMetricsServer(addr string, r *Runner, log *slog.Logger) *metricsServer {
return &metricsServer{addr: addr, r: r, log: log}
}
// ListenAndServe starts the HTTP server and blocks until ctx is cancelled or
// a fatal listen error occurs.
func (ms *metricsServer) ListenAndServe(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /metrics", ms.handleMetrics)
mux.HandleFunc("GET /health", ms.handleHealth)
srv := &http.Server{
Addr: ms.addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
BaseContext: func(_ net.Listener) context.Context { return ctx },
}
errCh := make(chan error, 1)
go func() {
ms.log.Info("runner: metrics server listening", "addr", ms.addr)
errCh <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
return nil
case err := <-errCh:
return fmt.Errorf("runner: metrics server: %w", err)
}
}
// handleMetrics handles GET /metrics.
// Response shape (JSON):
//
// {
// "tasks_running": N,
// "tasks_completed": N,
// "tasks_failed": N,
// "uptime_seconds": N
// }
func (ms *metricsServer) handleMetrics(w http.ResponseWriter, _ *http.Request) {
uptimeSec := int64(time.Since(ms.r.startedAt).Seconds())
metricsWriteJSON(w, 0, map[string]int64{
"tasks_running": ms.r.tasksRunning.Load(),
"tasks_completed": ms.r.tasksCompleted.Load(),
"tasks_failed": ms.r.tasksFailed.Load(),
"uptime_seconds": uptimeSec,
})
}
// handleHealth handles GET /health — simple liveness probe for the metrics server.
func (ms *metricsServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
metricsWriteJSON(w, 0, map[string]string{"status": "ok"})
}
// metricsWriteJSON writes v as a JSON response with the given status code.
func metricsWriteJSON(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)
}

View File

@@ -0,0 +1,440 @@
// Package runner implements the worker loop that polls PocketBase for pending
// scrape and audio tasks, executes them, and reports results back.
//
// Design:
// - Run(ctx) loops on a ticker; each tick claims and dispatches pending tasks.
// - Scrape tasks are dispatched to the Orchestrator (one goroutine per task,
// up to MaxConcurrentScrape).
// - Audio tasks fetch chapter text, call Kokoro, upload to MinIO, and report
// the result back (up to MaxConcurrentAudio goroutines).
// - 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
import (
"context"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/orchestrator"
"github.com/libnovel/backend/internal/scraper"
"github.com/libnovel/backend/internal/taskqueue"
)
// Config tunes the runner behaviour.
type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records.
WorkerID string
// PollInterval is how often the runner checks for new tasks.
PollInterval time.Duration
// MaxConcurrentScrape limits simultaneous book-scrape goroutines.
MaxConcurrentScrape int
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
MaxConcurrentAudio int
// OrchestratorWorkers is the chapter-scraping parallelism inside each book run.
OrchestratorWorkers int
// HeartbeatInterval is how often active tasks PATCH their heartbeat_at
// timestamp to signal they are still alive. Defaults to 30s when 0.
HeartbeatInterval time.Duration
// 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.
StaleTaskThreshold time.Duration
// BrowseRefreshInterval is how often the runner pre-fetches browse page
// snapshots from novelfire.net and stores them in MinIO. Defaults to 6h.
BrowseRefreshInterval time.Duration
// CatalogueRefreshInterval is how often the runner walks the full catalogue,
// scrapes per-book metadata, downloads covers, and re-indexes everything in
// Meilisearch. Defaults to 24h (expensive — full catalogue walk).
CatalogueRefreshInterval time.Duration
// 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.
type Dependencies struct {
// Consumer claims tasks from PocketBase.
Consumer taskqueue.Consumer
// BookWriter persists scraped data (used by orchestrator).
BookWriter bookstore.BookWriter
// BookReader reads chapter text for audio generation.
BookReader bookstore.BookReader
// AudioStore persists generated audio and checks key existence.
AudioStore bookstore.AudioStore
// BrowseStore stores browse page snapshots in MinIO.
BrowseStore bookstore.BrowseStore
// CoverStore stores book cover images in MinIO.
// If nil, cover downloads are skipped during catalogue refresh.
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 scraper.NovelScraper
// Kokoro is the TTS client.
Kokoro kokoro.Client
// Log is the structured logger.
Log *slog.Logger
}
// Runner is the main worker process.
type Runner struct {
cfg Config
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.
func New(cfg Config, deps Dependencies) *Runner {
if cfg.PollInterval <= 0 {
cfg.PollInterval = 30 * time.Second
}
if cfg.MaxConcurrentScrape <= 0 {
cfg.MaxConcurrentScrape = 2
}
if cfg.MaxConcurrentAudio <= 0 {
cfg.MaxConcurrentAudio = 1
}
if cfg.WorkerID == "" {
cfg.WorkerID = "runner"
}
if cfg.HeartbeatInterval <= 0 {
cfg.HeartbeatInterval = 30 * time.Second
}
if cfg.StaleTaskThreshold <= 0 {
cfg.StaleTaskThreshold = 2 * time.Minute
}
if cfg.BrowseRefreshInterval <= 0 {
cfg.BrowseRefreshInterval = 6 * time.Hour
}
if cfg.CatalogueRefreshInterval <= 0 {
cfg.CatalogueRefreshInterval = 24 * time.Hour
}
if cfg.MetricsAddr == "" {
cfg.MetricsAddr = ":9091"
}
if deps.Log == nil {
deps.Log = slog.Default()
}
if deps.SearchIndex == nil {
deps.SearchIndex = meili.NoopClient{}
}
return &Runner{cfg: cfg, deps: deps, startedAt: time.Now()}
}
// Run starts the poll loop and the metrics HTTP server, blocking until ctx is
// cancelled.
func (r *Runner) Run(ctx context.Context) error {
r.deps.Log.Info("runner: starting",
"worker_id", r.cfg.WorkerID,
"poll_interval", r.cfg.PollInterval,
"max_scrape", r.cfg.MaxConcurrentScrape,
"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)
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
var wg sync.WaitGroup
tick := time.NewTicker(r.cfg.PollInterval)
defer tick.Stop()
browseTick := time.NewTicker(r.cfg.BrowseRefreshInterval)
defer browseTick.Stop()
catalogueTick := time.NewTicker(r.cfg.CatalogueRefreshInterval)
defer catalogueTick.Stop()
// Run one browse refresh immediately on startup.
go r.runBrowseRefresh(ctx)
// Run one catalogue refresh immediately on startup.
go r.runCatalogueRefresh(ctx)
// Run one poll immediately on startup, then on each tick.
for {
r.poll(ctx, scrapeSem, audioSem, &wg)
select {
case <-ctx.Done():
r.deps.Log.Info("runner: context cancelled, draining active tasks")
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
r.deps.Log.Info("runner: all tasks drained, exiting")
case <-time.After(2 * time.Minute):
r.deps.Log.Warn("runner: drain timeout exceeded, forcing exit")
}
return nil
case <-browseTick.C:
go r.runBrowseRefresh(ctx)
case <-catalogueTick.C:
go r.runCatalogueRefresh(ctx)
case <-tick.C:
}
}
}
// poll claims all available pending tasks and dispatches them to goroutines.
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) {
// ── Reap orphaned tasks ───────────────────────────────────────────────
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
r.deps.Log.Warn("runner: reap stale tasks failed", "err", err)
} else if n > 0 {
r.deps.Log.Info("runner: reaped stale tasks", "count", n)
}
// ── Scrape tasks ──────────────────────────────────────────────────────
for {
if ctx.Err() != nil {
return
}
task, ok, err := r.deps.Consumer.ClaimNextScrapeTask(ctx, r.cfg.WorkerID)
if err != nil {
r.deps.Log.Error("runner: ClaimNextScrapeTask failed", "err", err)
break
}
if !ok {
break
}
select {
case scrapeSem <- struct{}{}:
default:
r.deps.Log.Warn("runner: scrape semaphore full, will retry next tick",
"task_id", task.ID)
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.ScrapeTask) {
defer wg.Done()
defer func() { <-scrapeSem }()
defer r.tasksRunning.Add(-1)
r.runScrapeTask(ctx, t)
}(task)
}
// ── Audio tasks ───────────────────────────────────────────────────────
for {
if ctx.Err() != nil {
return
}
task, ok, err := r.deps.Consumer.ClaimNextAudioTask(ctx, r.cfg.WorkerID)
if err != nil {
r.deps.Log.Error("runner: ClaimNextAudioTask failed", "err", err)
break
}
if !ok {
break
}
select {
case audioSem <- struct{}{}:
default:
r.deps.Log.Warn("runner: audio semaphore full, will retry next tick",
"task_id", task.ID)
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.AudioTask) {
defer wg.Done()
defer func() { <-audioSem }()
defer r.tasksRunning.Add(-1)
r.runAudioTask(ctx, t)
}(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.
func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
log := r.deps.Log.With("task_id", task.ID, "kind", task.Kind, "url", task.TargetURL)
log.Info("runner: scrape task starting")
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
go func() {
tick := time.NewTicker(r.cfg.HeartbeatInterval)
defer tick.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-tick.C:
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
log.Warn("runner: heartbeat failed", "err", err)
}
}
}
}()
o := r.newOrchestrator()
var result domain.ScrapeResult
switch task.Kind {
case "catalogue":
result = r.runCatalogueTask(ctx, task, o, log)
case "book", "book_range":
result = o.RunBook(ctx, task)
default:
result.ErrorMessage = fmt.Sprintf("unknown task kind: %q", task.Kind)
log.Warn("runner: unknown task kind")
}
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishScrapeTask failed", "err", err)
}
if result.ErrorMessage != "" {
r.tasksFailed.Add(1)
} else {
r.tasksCompleted.Add(1)
}
log.Info("runner: scrape task finished",
"scraped", result.ChaptersScraped,
"skipped", result.ChaptersSkipped,
"errors", result.Errors,
)
}
// runCatalogueTask runs a full catalogue scrape.
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)
var result domain.ScrapeResult
for entry := range entries {
if ctx.Err() != nil {
break
}
bookTask := domain.ScrapeTask{
ID: task.ID,
Kind: "book",
TargetURL: entry.URL,
}
bookResult := o.RunBook(ctx, bookTask)
result.BooksFound += bookResult.BooksFound + 1
result.ChaptersScraped += bookResult.ChaptersScraped
result.ChaptersSkipped += bookResult.ChaptersSkipped
result.Errors += bookResult.Errors
}
if err := <-errCh; err != nil {
log.Warn("runner: catalogue scrape finished with error", "err", err)
result.Errors++
if result.ErrorMessage == "" {
result.ErrorMessage = err.Error()
}
}
return result
}
// runAudioTask executes one audio-generation task.
func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "voice", task.Voice)
log.Info("runner: audio task starting")
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
go func() {
tick := time.NewTicker(r.cfg.HeartbeatInterval)
defer tick.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-tick.C:
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
log.Warn("runner: heartbeat failed", "err", err)
}
}
}
}()
fail := func(msg string) {
log.Error("runner: audio task failed", "reason", msg)
r.tasksFailed.Add(1)
result := domain.AudioResult{ErrorMessage: msg}
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishAudioTask failed", "err", err)
}
}
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
if err != nil {
fail(fmt.Sprintf("read chapter: %v", err))
return
}
text := stripMarkdown(raw)
if text == "" {
fail("chapter text is empty after stripping markdown")
return
}
if r.deps.Kokoro == nil {
fail("kokoro client not configured")
return
}
audioData, err := r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
if err != nil {
fail(fmt.Sprintf("kokoro generate: %v", err))
return
}
key := r.deps.AudioStore.AudioObjectKey(task.Slug, task.Chapter, task.Voice)
if err := r.deps.AudioStore.PutAudio(ctx, key, audioData); err != nil {
fail(fmt.Sprintf("put audio: %v", err))
return
}
r.tasksCompleted.Add(1)
result := domain.AudioResult{ObjectKey: key}
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishAudioTask failed", "err", err)
}
log.Info("runner: audio task finished", "key", key)
}

View File

@@ -0,0 +1,365 @@
package runner_test
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/runner"
)
// ── Stub types ────────────────────────────────────────────────────────────────
// stubConsumer is a test double for taskqueue.Consumer.
type stubConsumer struct {
scrapeQueue []domain.ScrapeTask
audioQueue []domain.AudioTask
scrapeIdx int
audioIdx int
finished []string
failCalled []string
claimErr error
}
func (s *stubConsumer) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
if s.claimErr != nil {
return domain.ScrapeTask{}, false, s.claimErr
}
if s.scrapeIdx >= len(s.scrapeQueue) {
return domain.ScrapeTask{}, false, nil
}
t := s.scrapeQueue[s.scrapeIdx]
s.scrapeIdx++
return t, true, nil
}
func (s *stubConsumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
if s.claimErr != nil {
return domain.AudioTask{}, false, s.claimErr
}
if s.audioIdx >= len(s.audioQueue) {
return domain.AudioTask{}, false, nil
}
t := s.audioQueue[s.audioIdx]
s.audioIdx++
return t, true, nil
}
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
s.finished = append(s.finished, id)
return nil
}
func (s *stubConsumer) FinishAudioTask(_ context.Context, id string, _ domain.AudioResult) error {
s.finished = append(s.finished, id)
return nil
}
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
s.failCalled = append(s.failCalled, id)
return nil
}
func (s *stubConsumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
func (s *stubConsumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) {
return 0, nil
}
// stubBookWriter satisfies bookstore.BookWriter (no-op).
type stubBookWriter struct{}
func (s *stubBookWriter) WriteMetadata(_ context.Context, _ domain.BookMeta) error { return nil }
func (s *stubBookWriter) WriteChapter(_ context.Context, _ string, _ domain.Chapter) error {
return nil
}
func (s *stubBookWriter) WriteChapterRefs(_ context.Context, _ string, _ []domain.ChapterRef) error {
return nil
}
func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.ChapterRef) bool {
return false
}
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
type stubBookReader struct {
text string
readErr error
}
func (s *stubBookReader) ReadChapter(_ context.Context, _ string, _ int) (string, error) {
return s.text, s.readErr
}
func (s *stubBookReader) ReadMetadata(_ context.Context, _ string) (domain.BookMeta, bool, error) {
return domain.BookMeta{}, false, nil
}
func (s *stubBookReader) ListBooks(_ context.Context) ([]domain.BookMeta, error) { return nil, nil }
func (s *stubBookReader) LocalSlugs(_ context.Context) (map[string]bool, error) { return nil, nil }
func (s *stubBookReader) MetadataMtime(_ context.Context, _ string) int64 { return 0 }
func (s *stubBookReader) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
return nil, nil
}
func (s *stubBookReader) CountChapters(_ context.Context, _ string) int { return 0 }
func (s *stubBookReader) ReindexChapters(_ context.Context, _ string) (int, error) {
return 0, nil
}
// stubAudioStore satisfies bookstore.AudioStore.
type stubAudioStore struct {
putCalled atomic.Int32
putErr error
}
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
}
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
s.putCalled.Add(1)
return s.putErr
}
// stubNovelScraper satisfies scraper.NovelScraper minimally.
type stubNovelScraper struct {
entries []domain.CatalogueEntry
metaErr error
chapters []domain.ChapterRef
}
func (s *stubNovelScraper) ScrapeCatalogue(_ context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
ch := make(chan domain.CatalogueEntry, len(s.entries))
errCh := make(chan error, 1)
for _, e := range s.entries {
ch <- e
}
close(ch)
close(errCh)
return ch, errCh
}
func (s *stubNovelScraper) ScrapeMetadata(_ context.Context, _ string) (domain.BookMeta, error) {
if s.metaErr != nil {
return domain.BookMeta{}, s.metaErr
}
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) {
return s.chapters, nil
}
func (s *stubNovelScraper) ScrapeChapterText(_ context.Context, ref domain.ChapterRef) (domain.Chapter, error) {
return domain.Chapter{Ref: ref, Text: "# Chapter\n\nSome text."}, nil
}
func (s *stubNovelScraper) ScrapeRanking(_ context.Context, _ int) (<-chan domain.BookMeta, <-chan error) {
ch := make(chan domain.BookMeta)
errCh := make(chan error, 1)
close(ch)
close(errCh)
return ch, errCh
}
func (s *stubNovelScraper) SourceName() string { return "stub" }
// stubKokoro satisfies kokoro.Client.
type stubKokoro struct {
data []byte
genErr error
called atomic.Int32
}
func (s *stubKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) {
s.called.Add(1)
return s.data, s.genErr
}
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
return []string{"af_bella"}, nil
}
// ── stripMarkdown helper ──────────────────────────────────────────────────────
func TestStripMarkdownViaAudioTask(t *testing.T) {
// Verify markdown is stripped before sending to Kokoro.
// We inject chapter text with markdown; the kokoro stub verifies data flows.
consumer := &stubConsumer{
audioQueue: []domain.AudioTask{
{ID: "a1", Slug: "book", Chapter: 1, Voice: "af_bella", Status: domain.TaskStatusRunning},
},
}
bookReader := &stubBookReader{text: "## Chapter 1\n\nPlain **text** here."}
audioStore := &stubAudioStore{}
kokoroStub := &stubKokoro{data: []byte("mp3")}
cfg := runner.Config{
WorkerID: "test",
PollInterval: time.Hour, // long poll — we'll cancel manually
}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: &stubBookWriter{},
BookReader: bookReader,
AudioStore: audioStore,
Novel: &stubNovelScraper{},
Kokoro: kokoroStub,
}
r := runner.New(cfg, deps)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = r.Run(ctx)
if kokoroStub.called.Load() != 1 {
t.Errorf("expected Kokoro.GenerateAudio called once, got %d", kokoroStub.called.Load())
}
if audioStore.putCalled.Load() != 1 {
t.Errorf("expected PutAudio called once, got %d", audioStore.putCalled.Load())
}
}
func TestAudioTask_ReadChapterError(t *testing.T) {
consumer := &stubConsumer{
audioQueue: []domain.AudioTask{
{ID: "a2", Slug: "book", Chapter: 2, Voice: "af_bella", Status: domain.TaskStatusRunning},
},
}
bookReader := &stubBookReader{readErr: errors.New("chapter not found")}
audioStore := &stubAudioStore{}
kokoroStub := &stubKokoro{data: []byte("mp3")}
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: &stubBookWriter{},
BookReader: bookReader,
AudioStore: audioStore,
Novel: &stubNovelScraper{},
Kokoro: kokoroStub,
}
r := runner.New(cfg, deps)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = r.Run(ctx)
// Kokoro should not be called; FinishAudioTask should be called with error.
if kokoroStub.called.Load() != 0 {
t.Errorf("expected Kokoro not called, got %d", kokoroStub.called.Load())
}
if len(consumer.finished) != 1 {
t.Errorf("expected FinishAudioTask called once, got %d", len(consumer.finished))
}
}
func TestAudioTask_KokoroError(t *testing.T) {
consumer := &stubConsumer{
audioQueue: []domain.AudioTask{
{ID: "a3", Slug: "book", Chapter: 3, Voice: "af_bella", Status: domain.TaskStatusRunning},
},
}
bookReader := &stubBookReader{text: "Chapter text."}
audioStore := &stubAudioStore{}
kokoroStub := &stubKokoro{genErr: errors.New("tts failed")}
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: &stubBookWriter{},
BookReader: bookReader,
AudioStore: audioStore,
Novel: &stubNovelScraper{},
Kokoro: kokoroStub,
}
r := runner.New(cfg, deps)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = r.Run(ctx)
if audioStore.putCalled.Load() != 0 {
t.Errorf("expected PutAudio not called, got %d", audioStore.putCalled.Load())
}
if len(consumer.finished) != 1 {
t.Errorf("expected FinishAudioTask called once, got %d", len(consumer.finished))
}
}
func TestScrapeTask_BookKind(t *testing.T) {
consumer := &stubConsumer{
scrapeQueue: []domain.ScrapeTask{
{ID: "s1", Kind: "book", TargetURL: "https://example.com/book/test-book", Status: domain.TaskStatusRunning},
},
}
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: &stubBookWriter{},
BookReader: &stubBookReader{},
AudioStore: &stubAudioStore{},
Novel: &stubNovelScraper{},
Kokoro: &stubKokoro{},
}
r := runner.New(cfg, deps)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = r.Run(ctx)
if len(consumer.finished) != 1 || consumer.finished[0] != "s1" {
t.Errorf("expected task s1 finished, got %v", consumer.finished)
}
}
func TestScrapeTask_UnknownKind(t *testing.T) {
consumer := &stubConsumer{
scrapeQueue: []domain.ScrapeTask{
{ID: "s2", Kind: "unknown_kind", Status: domain.TaskStatusRunning},
},
}
cfg := runner.Config{WorkerID: "test", PollInterval: time.Hour}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: &stubBookWriter{},
BookReader: &stubBookReader{},
AudioStore: &stubAudioStore{},
Novel: &stubNovelScraper{},
Kokoro: &stubKokoro{},
}
r := runner.New(cfg, deps)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = r.Run(ctx)
// Unknown kind still finishes the task (with error message in result).
if len(consumer.finished) != 1 || consumer.finished[0] != "s2" {
t.Errorf("expected task s2 finished, got %v", consumer.finished)
}
}
func TestRun_CancelImmediately(t *testing.T) {
consumer := &stubConsumer{}
cfg := runner.Config{WorkerID: "test", PollInterval: 10 * time.Millisecond}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: &stubBookWriter{},
BookReader: &stubBookReader{},
AudioStore: &stubAudioStore{},
Novel: &stubNovelScraper{},
Kokoro: &stubKokoro{},
}
r := runner.New(cfg, deps)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before Run
err := r.Run(ctx)
if err != nil {
t.Errorf("expected nil on graceful shutdown, got %v", err)
}
}

View File

@@ -0,0 +1,58 @@
// Package scraper defines the NovelScraper interface and its sub-interfaces.
// Domain types live in internal/domain — this package only defines the scraping
// contract so that novelfire and any future scrapers can be swapped freely.
package scraper
import (
"context"
"github.com/libnovel/backend/internal/domain"
)
// CatalogueProvider can enumerate every novel available on a source site.
type CatalogueProvider interface {
ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error)
}
// MetadataProvider can extract structured book metadata from a novel's landing page.
type MetadataProvider interface {
ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error)
}
// ChapterListProvider can enumerate all chapters of a book.
type ChapterListProvider interface {
ScrapeChapterList(ctx context.Context, bookURL string) ([]domain.ChapterRef, error)
}
// ChapterTextProvider can extract the readable text from a single chapter page.
type ChapterTextProvider interface {
ScrapeChapterText(ctx context.Context, ref domain.ChapterRef) (domain.Chapter, error)
}
// RankingProvider can enumerate novels from a ranking page.
type RankingProvider interface {
// ScrapeRanking pages through up to maxPages ranking pages.
// maxPages <= 0 means all pages.
ScrapeRanking(ctx context.Context, maxPages int) (<-chan domain.BookMeta, <-chan error)
}
// NovelScraper is the full interface a concrete novel source must implement.
type NovelScraper interface {
CatalogueProvider
MetadataProvider
ChapterListProvider
ChapterTextProvider
RankingProvider
// SourceName returns the human-readable name of this scraper, e.g. "novelfire.net".
SourceName() string
}
// Selector describes how to locate an element in an HTML document.
type Selector struct {
Tag string
Class string
ID string
Attr string
Multiple bool
}

View File

@@ -0,0 +1,270 @@
package storage
import (
"context"
"fmt"
"io"
"net/url"
"path"
"strings"
"time"
minio "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/libnovel/backend/internal/config"
)
// minioClient wraps the official minio-go client with bucket names.
type minioClient struct {
client *minio.Client // internal — all read/write operations
pubClient *minio.Client // presign-only — initialised against the public endpoint
bucketChapters string
bucketAudio string
bucketAvatars string
bucketBrowse string
}
func newMinioClient(cfg config.MinIO) (*minioClient, error) {
creds := credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, "")
internal, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: creds,
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("minio: init internal client: %w", err)
}
// Presigned URLs must be signed with the hostname the browser will use
// (PUBLIC_MINIO_PUBLIC_URL), because AWS Signature V4 includes the Host
// header in the canonical request — a URL signed against "minio:9000" will
// return SignatureDoesNotMatch when the browser fetches it from
// "localhost:9000".
//
// However, minio-go normally makes a live BucketLocation HTTP call before
// signing, which would fail from inside the container when the public
// endpoint is externally-facing (e.g. "localhost:9000" is unreachable from
// within Docker). We prevent this by:
// 1. Setting Region: "us-east-1" — minio-go skips getBucketLocation when
// the region is already known (bucket-cache.go:49).
// 2. Setting BucketLookup: BucketLookupPath — forces path-style URLs
// (e.g. host/bucket/key), matching MinIO's default behaviour and
// avoiding any virtual-host DNS probing.
//
// When no public endpoint is configured (or it equals the internal one),
// fall back to the internal client so presigning still works.
publicEndpoint := cfg.PublicEndpoint
if u, err2 := url.Parse(publicEndpoint); err2 == nil && u.Host != "" {
publicEndpoint = u.Host // strip scheme so minio.New is happy
}
pubUseSSL := cfg.PublicUseSSL
if publicEndpoint == "" || publicEndpoint == cfg.Endpoint {
publicEndpoint = cfg.Endpoint
pubUseSSL = cfg.UseSSL
}
pub, err := minio.New(publicEndpoint, &minio.Options{
Creds: creds,
Secure: pubUseSSL,
Region: "us-east-1", // skip live BucketLocation preflight
BucketLookup: minio.BucketLookupPath,
})
if err != nil {
return nil, fmt.Errorf("minio: init public client: %w", err)
}
return &minioClient{
client: internal,
pubClient: pub,
bucketChapters: cfg.BucketChapters,
bucketAudio: cfg.BucketAudio,
bucketAvatars: cfg.BucketAvatars,
bucketBrowse: cfg.BucketBrowse,
}, nil
}
// ensureBuckets creates all required buckets if they don't already exist.
func (m *minioClient) ensureBuckets(ctx context.Context) error {
for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars, m.bucketBrowse} {
exists, err := m.client.BucketExists(ctx, bucket)
if err != nil {
return fmt.Errorf("minio: check bucket %q: %w", bucket, err)
}
if !exists {
if err := m.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
return fmt.Errorf("minio: create bucket %q: %w", bucket, err)
}
}
}
return nil
}
// ── Key helpers ───────────────────────────────────────────────────────────────
// ChapterObjectKey returns the MinIO object key for a chapter markdown file.
// Format: {slug}/chapter-{n:06d}.md
func ChapterObjectKey(slug string, n int) string {
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
}
// AudioObjectKey returns the MinIO object key for a cached audio file.
// Format: {slug}/{n}/{voice}.mp3
func AudioObjectKey(slug string, n int, voice string) string {
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
}
// AvatarObjectKey returns the MinIO object key for a user avatar image.
// Format: {userID}/{ext}.{ext}
func AvatarObjectKey(userID, ext string) string {
return fmt.Sprintf("%s/%s.%s", userID, ext, ext)
}
// BrowseObjectKey returns the MinIO object key for a cached browse page snapshot.
// Format: browse/{genre}/{sort}/{status}/{type}/page-{n}.json
func BrowseObjectKey(genre, sort, status, novelType string, page int) string {
return fmt.Sprintf("browse/%s/%s/%s/%s/page-%d.json", genre, sort, status, novelType, page)
}
// CoverObjectKey returns the MinIO object key for a book cover image.
// Format: covers/{slug}.jpg
func CoverObjectKey(slug string) string {
return fmt.Sprintf("covers/%s.jpg", slug)
}
// chapterNumberFromKey extracts the chapter number from a MinIO object key.
// e.g. "my-book/chapter-000042.md" → 42
func chapterNumberFromKey(key string) int {
base := path.Base(key)
base = strings.TrimPrefix(base, "chapter-")
base = strings.TrimSuffix(base, ".md")
var n int
fmt.Sscanf(base, "%d", &n)
return n
}
// ── Object operations ─────────────────────────────────────────────────────────
func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType string, data []byte) error {
_, err := m.client.PutObject(ctx, bucket, key,
strings.NewReader(string(data)),
int64(len(data)),
minio.PutObjectOptions{ContentType: contentType},
)
return err
}
func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) {
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
defer obj.Close()
return io.ReadAll(obj)
}
func (m *minioClient) objectExists(ctx context.Context, bucket, key string) bool {
_, err := m.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
return err == nil
}
func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) {
u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil)
if err != nil {
return "", fmt.Errorf("minio presign %s/%s: %w", bucket, key, err)
}
return u.String(), nil
}
func (m *minioClient) presignPut(ctx context.Context, bucket, key string, expires time.Duration) (string, error) {
u, err := m.pubClient.PresignedPutObject(ctx, bucket, key, expires)
if err != nil {
return "", fmt.Errorf("minio presign PUT %s/%s: %w", bucket, key, err)
}
return u.String(), nil
}
func (m *minioClient) deleteObjects(ctx context.Context, bucket, prefix string) error {
objCh := m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix})
for obj := range objCh {
if obj.Err != nil {
return obj.Err
}
if err := m.client.RemoveObject(ctx, bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil {
return err
}
}
return nil
}
func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string) ([]string, error) {
var keys []string
for obj := range m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) {
if obj.Err != nil {
return nil, obj.Err
}
keys = append(keys, obj.Key)
}
return keys, nil
}
// ── Browse operations ─────────────────────────────────────────────────────────
// putBrowse stores raw JSON bytes for a browse page snapshot.
func (m *minioClient) putBrowse(ctx context.Context, key string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, "application/json", data)
}
// getBrowse retrieves a browse page snapshot. Returns (nil, false, nil) when
// the object does not exist.
func (m *minioClient) getBrowse(ctx context.Context, key string) ([]byte, bool, error) {
if !m.objectExists(ctx, m.bucketBrowse, key) {
return nil, false, nil
}
data, err := m.getObject(ctx, m.bucketBrowse, key)
if err != nil {
return nil, false, err
}
return data, true, nil
}
// ── Cover operations ──────────────────────────────────────────────────────────
// putCover stores a raw cover image in the browse bucket under covers/{slug}.jpg.
func (m *minioClient) putCover(ctx context.Context, key, contentType string, data []byte) error {
return m.putObject(ctx, m.bucketBrowse, key, contentType, data)
}
// getCover retrieves a cover image. Returns (nil, "", false, nil) when the
// object does not exist.
func (m *minioClient) getCover(ctx context.Context, key string) ([]byte, bool, error) {
if !m.objectExists(ctx, m.bucketBrowse, key) {
return nil, false, nil
}
data, err := m.getObject(ctx, m.bucketBrowse, key)
if err != nil {
return nil, false, err
}
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"
}

View File

@@ -0,0 +1,268 @@
// Package storage provides the concrete implementations of all bookstore and
// taskqueue interfaces backed by PocketBase (structured data) and MinIO (blobs).
//
// Entry point: NewStore(ctx, cfg, log) returns a *Store that satisfies every
// interface defined in bookstore and taskqueue.
package storage
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/domain"
)
// ErrNotFound is returned by single-record lookups when no record exists.
var ErrNotFound = errors.New("storage: record not found")
// pbClient is the internal PocketBase REST admin client.
type pbClient struct {
baseURL string
email string
password string
log *slog.Logger
mu sync.Mutex
token string
exp time.Time
}
func newPBClient(cfg config.PocketBase, log *slog.Logger) *pbClient {
return &pbClient{
baseURL: strings.TrimRight(cfg.URL, "/"),
email: cfg.AdminEmail,
password: cfg.AdminPassword,
log: log,
}
}
// authToken returns a valid admin auth token, refreshing it when expired.
func (c *pbClient) authToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.exp) {
return c.token, nil
}
body, _ := json.Marshal(map[string]string{
"identity": c.email,
"password": c.password,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("pb auth: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("pb auth: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("pb auth: status %d: %s", resp.StatusCode, string(raw))
}
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("pb auth: decode: %w", err)
}
c.token = payload.Token
c.exp = time.Now().Add(30 * time.Minute)
return c.token, nil
}
// do executes an authenticated PocketBase REST request.
func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
tok, err := c.authToken(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, fmt.Errorf("pb: build request %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", tok)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
}
return resp, nil
}
// get is a convenience wrapper that decodes a JSON response into v.
func (c *pbClient) get(ctx context.Context, path string, v any) error {
resp, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pb GET %s: status %d: %s", path, resp.StatusCode, string(raw))
}
return json.NewDecoder(resp.Body).Decode(v)
}
// post creates a record and decodes the created record into v.
func (c *pbClient) post(ctx context.Context, path string, payload, v any) error {
b, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("pb: marshal: %w", err)
}
resp, err := c.do(ctx, http.MethodPost, path, bytes.NewReader(b))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pb POST %s: status %d: %s", path, resp.StatusCode, string(raw))
}
if v != nil {
return json.NewDecoder(resp.Body).Decode(v)
}
return nil
}
// patch updates a record.
func (c *pbClient) patch(ctx context.Context, path string, payload any) error {
b, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("pb: marshal: %w", err)
}
resp, err := c.do(ctx, http.MethodPatch, path, bytes.NewReader(b))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pb PATCH %s: status %d: %s", path, resp.StatusCode, string(raw))
}
return nil
}
// delete removes a record.
func (c *pbClient) delete(ctx context.Context, path string) error {
resp, err := c.do(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pb DELETE %s: status %d: %s", path, resp.StatusCode, string(raw))
}
return nil
}
// listAll fetches all pages of a collection. PocketBase returns at most 200
// records per page; we paginate until empty.
func (c *pbClient) listAll(ctx context.Context, collection string, filter, sort string) ([]json.RawMessage, error) {
var all []json.RawMessage
page := 1
for {
q := url.Values{
"page": {fmt.Sprintf("%d", page)},
"perPage": {"200"},
}
if filter != "" {
q.Set("filter", filter)
}
if sort != "" {
q.Set("sort", sort)
}
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
var result struct {
Items []json.RawMessage `json:"items"`
Page int `json:"page"`
Pages int `json:"totalPages"`
}
if err := c.get(ctx, path, &result); err != nil {
return nil, err
}
all = append(all, result.Items...)
if result.Page >= result.Pages {
break
}
page++
}
return all, nil
}
// claimRecord atomically claims the first pending record matching collection.
// It fetches the oldest pending record (filter + sort), then PATCHes it with
// the claim payload. Returns (nil, nil) when the queue is empty.
func (c *pbClient) claimRecord(ctx context.Context, collection, workerID string, extraClaim map[string]any) (json.RawMessage, error) {
q := url.Values{}
q.Set("filter", `status="pending"`)
q.Set("sort", "+started")
q.Set("perPage", "1")
path := fmt.Sprintf("/api/collections/%s/records?%s", collection, q.Encode())
var result struct {
Items []json.RawMessage `json:"items"`
}
if err := c.get(ctx, path, &result); err != nil {
return nil, fmt.Errorf("claimRecord list: %w", err)
}
if len(result.Items) == 0 {
return nil, nil // queue empty
}
var rec struct {
ID string `json:"id"`
}
if err := json.Unmarshal(result.Items[0], &rec); err != nil {
return nil, fmt.Errorf("claimRecord parse id: %w", err)
}
claim := map[string]any{
"status": string(domain.TaskStatusRunning),
"worker_id": workerID,
}
for k, v := range extraClaim {
claim[k] = v
}
claimPath := fmt.Sprintf("/api/collections/%s/records/%s", collection, rec.ID)
if err := c.patch(ctx, claimPath, claim); err != nil {
return nil, fmt.Errorf("claimRecord patch: %w", err)
}
// Re-fetch the updated record so caller has current state.
var updated json.RawMessage
if err := c.get(ctx, claimPath, &updated); err != nil {
return nil, fmt.Errorf("claimRecord re-fetch: %w", err)
}
return updated, nil
}

View File

@@ -0,0 +1,824 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/taskqueue"
)
// Store is the unified persistence implementation that satisfies all bookstore
// and taskqueue interfaces. It routes structured data to PocketBase and binary
// blobs to MinIO.
type Store struct {
pb *pbClient
mc *minioClient
log *slog.Logger
}
// NewStore initialises PocketBase and MinIO connections and ensures all MinIO
// buckets exist. Returns a ready-to-use Store.
func NewStore(ctx context.Context, cfg config.Config, log *slog.Logger) (*Store, error) {
pb := newPBClient(cfg.PocketBase, log)
// Validate PocketBase connectivity by fetching an auth token.
if _, err := pb.authToken(ctx); err != nil {
return nil, fmt.Errorf("pocketbase: %w", err)
}
mc, err := newMinioClient(cfg.MinIO)
if err != nil {
return nil, fmt.Errorf("minio: %w", err)
}
if err := mc.ensureBuckets(ctx); err != nil {
return nil, fmt.Errorf("minio: ensure buckets: %w", err)
}
return &Store{pb: pb, mc: mc, log: log}, nil
}
// Compile-time interface satisfaction.
var _ bookstore.BookWriter = (*Store)(nil)
var _ bookstore.BookReader = (*Store)(nil)
var _ bookstore.RankingStore = (*Store)(nil)
var _ bookstore.AudioStore = (*Store)(nil)
var _ bookstore.PresignStore = (*Store)(nil)
var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.BrowseStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
// ── BookWriter ────────────────────────────────────────────────────────────────
func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
payload := map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"author": meta.Author,
"cover": meta.Cover,
"status": meta.Status,
"genres": meta.Genres,
"summary": meta.Summary,
"total_chapters": meta.TotalChapters,
"source_url": meta.SourceURL,
"ranking": meta.Ranking,
"rating": meta.Rating,
}
// Upsert via filter: if exists PATCH, otherwise POST.
existing, err := s.getBookBySlug(ctx, meta.Slug)
if err != nil && err != ErrNotFound {
return fmt.Errorf("WriteMetadata: %w", err)
}
if err == ErrNotFound {
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
}
func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error {
key := ChapterObjectKey(slug, chapter.Ref.Number)
if err := s.mc.putObject(ctx, s.mc.bucketChapters, key, "text/markdown", []byte(chapter.Text)); err != nil {
return fmt.Errorf("WriteChapter: minio: %w", err)
}
// Upsert the chapters_idx record in PocketBase.
return s.upsertChapterIdx(ctx, slug, chapter.Ref)
}
func (s *Store) WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error {
for _, ref := range refs {
if err := s.upsertChapterIdx(ctx, slug, ref); err != nil {
s.log.Warn("WriteChapterRefs: upsert failed", "slug", slug, "chapter", ref.Number, "err", err)
}
}
return nil
}
func (s *Store) ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool {
return s.mc.objectExists(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, ref.Number))
}
func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.ChapterRef) error {
payload := map[string]any{
"slug": slug,
"number": ref.Number,
"title": ref.Title,
}
filter := fmt.Sprintf(`slug=%q&&number=%d`, slug, ref.Number)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil && err != ErrNotFound {
return err
}
if len(items) == 0 {
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
}
var rec struct {
ID string `json:"id"`
}
json.Unmarshal(items[0], &rec)
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
}
// ── BookReader ────────────────────────────────────────────────────────────────
type pbBook struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover"`
Status string `json:"status"`
Genres []string `json:"genres"`
Summary string `json:"summary"`
TotalChapters int `json:"total_chapters"`
SourceURL string `json:"source_url"`
Ranking int `json:"ranking"`
Rating float64 `json:"rating"`
Updated string `json:"updated"`
}
func (b pbBook) toDomain() domain.BookMeta {
return domain.BookMeta{
Slug: b.Slug,
Title: b.Title,
Author: b.Author,
Cover: b.Cover,
Status: b.Status,
Genres: b.Genres,
Summary: b.Summary,
TotalChapters: b.TotalChapters,
SourceURL: b.SourceURL,
Ranking: b.Ranking,
Rating: b.Rating,
}
}
func (s *Store) getBookBySlug(ctx context.Context, slug string) (pbBook, error) {
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "books", filter, "")
if err != nil {
return pbBook{}, err
}
if len(items) == 0 {
return pbBook{}, ErrNotFound
}
var b pbBook
json.Unmarshal(items[0], &b)
return b, nil
}
func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error) {
b, err := s.getBookBySlug(ctx, slug)
if err == ErrNotFound {
return domain.BookMeta{}, false, nil
}
if err != nil {
return domain.BookMeta{}, false, err
}
return b.toDomain(), true, nil
}
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
items, err := s.pb.listAll(ctx, "books", "", "title")
if err != nil {
return nil, err
}
books := make([]domain.BookMeta, 0, len(items))
for _, raw := range items {
var b pbBook
json.Unmarshal(raw, &b)
books = append(books, b.toDomain())
}
return books, nil
}
func (s *Store) LocalSlugs(ctx context.Context) (map[string]bool, error) {
items, err := s.pb.listAll(ctx, "books", "", "")
if err != nil {
return nil, err
}
slugs := make(map[string]bool, len(items))
for _, raw := range items {
var b struct {
Slug string `json:"slug"`
}
json.Unmarshal(raw, &b)
if b.Slug != "" {
slugs[b.Slug] = true
}
}
return slugs, nil
}
func (s *Store) MetadataMtime(ctx context.Context, slug string) int64 {
b, err := s.getBookBySlug(ctx, slug)
if err != nil {
return 0
}
t, err := time.Parse(time.RFC3339, b.Updated)
if err != nil {
return 0
}
return t.Unix()
}
func (s *Store) ReadChapter(ctx context.Context, slug string, n int) (string, error) {
data, err := s.mc.getObject(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, n))
if err != nil {
return "", fmt.Errorf("ReadChapter: %w", err)
}
return string(data), nil
}
func (s *Store) ListChapters(ctx context.Context, slug string) ([]domain.ChapterInfo, error) {
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
if err != nil {
return nil, err
}
chapters := make([]domain.ChapterInfo, 0, len(items))
for _, raw := range items {
var rec struct {
Number int `json:"number"`
Title string `json:"title"`
}
json.Unmarshal(raw, &rec)
chapters = append(chapters, domain.ChapterInfo{Number: rec.Number, Title: rec.Title})
}
return chapters, nil
}
func (s *Store) CountChapters(ctx context.Context, slug string) int {
chapters, err := s.ListChapters(ctx, slug)
if err != nil {
return 0
}
return len(chapters)
}
func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
keys, err := s.mc.listObjectKeys(ctx, s.mc.bucketChapters, slug+"/")
if err != nil {
return 0, fmt.Errorf("ReindexChapters: list objects: %w", err)
}
count := 0
for _, key := range keys {
if !strings.HasSuffix(key, ".md") {
continue
}
n := chapterNumberFromKey(key)
if n == 0 {
continue
}
ref := domain.ChapterRef{Number: n}
if err := s.upsertChapterIdx(ctx, slug, ref); err != nil {
s.log.Warn("ReindexChapters: upsert failed", "key", key, "err", err)
continue
}
count++
}
return count, nil
}
// ── RankingStore ──────────────────────────────────────────────────────────────
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {
payload := map[string]any{
"rank": item.Rank,
"slug": item.Slug,
"title": item.Title,
"author": item.Author,
"cover": item.Cover,
"status": item.Status,
"genres": item.Genres,
"source_url": item.SourceURL,
}
filter := fmt.Sprintf(`slug=%q`, item.Slug)
items, err := s.pb.listAll(ctx, "ranking", filter, "")
if err != nil && err != ErrNotFound {
return err
}
if len(items) == 0 {
return s.pb.post(ctx, "/api/collections/ranking/records", payload, nil)
}
var rec struct {
ID string `json:"id"`
}
json.Unmarshal(items[0], &rec)
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/ranking/records/%s", rec.ID), payload)
}
func (s *Store) ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error) {
items, err := s.pb.listAll(ctx, "ranking", "", "rank")
if err != nil {
return nil, err
}
result := make([]domain.RankingItem, 0, len(items))
for _, raw := range items {
var rec struct {
Rank int `json:"rank"`
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Cover string `json:"cover"`
Status string `json:"status"`
Genres []string `json:"genres"`
SourceURL string `json:"source_url"`
Updated string `json:"updated"`
}
json.Unmarshal(raw, &rec)
t, _ := time.Parse(time.RFC3339, rec.Updated)
result = append(result, domain.RankingItem{
Rank: rec.Rank,
Slug: rec.Slug,
Title: rec.Title,
Author: rec.Author,
Cover: rec.Cover,
Status: rec.Status,
Genres: rec.Genres,
SourceURL: rec.SourceURL,
Updated: t,
})
}
return result, nil
}
func (s *Store) RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) {
items, err := s.ReadRankingItems(ctx)
if err != nil || len(items) == 0 {
return false, err
}
var latest time.Time
for _, item := range items {
if item.Updated.After(latest) {
latest = item.Updated
}
}
return time.Since(latest) < maxAge, nil
}
// ── AudioStore ────────────────────────────────────────────────────────────────
func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
return AudioObjectKey(slug, n, voice)
}
func (s *Store) AudioExists(ctx context.Context, key string) bool {
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
}
func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
}
// ── PresignStore ──────────────────────────────────────────────────────────────
func (s *Store) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
return s.mc.presignGet(ctx, s.mc.bucketChapters, ChapterObjectKey(slug, n), expires)
}
func (s *Store) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
return s.mc.presignGet(ctx, s.mc.bucketAudio, key, expires)
}
func (s *Store) PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) {
key = AvatarObjectKey(userID, ext)
uploadURL, err = s.mc.presignPut(ctx, s.mc.bucketAvatars, key, 15*time.Minute)
return
}
func (s *Store) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
for _, ext := range []string{"jpg", "png", "webp"} {
key := AvatarObjectKey(userID, ext)
if s.mc.objectExists(ctx, s.mc.bucketAvatars, key) {
u, err := s.mc.presignGet(ctx, s.mc.bucketAvatars, key, 1*time.Hour)
return u, true, err
}
}
return "", false, nil
}
func (s *Store) DeleteAvatar(ctx context.Context, userID string) error {
return s.mc.deleteObjects(ctx, s.mc.bucketAvatars, userID+"/")
}
// ── ProgressStore ─────────────────────────────────────────────────────────────
func (s *Store) GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool) {
filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, slug)
items, err := s.pb.listAll(ctx, "progress", filter, "")
if err != nil || len(items) == 0 {
return domain.ReadingProgress{}, false
}
var rec struct {
Slug string `json:"slug"`
Chapter int `json:"chapter"`
UpdatedAt string `json:"updated"`
}
json.Unmarshal(items[0], &rec)
t, _ := time.Parse(time.RFC3339, rec.UpdatedAt)
return domain.ReadingProgress{Slug: rec.Slug, Chapter: rec.Chapter, UpdatedAt: t}, true
}
func (s *Store) SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error {
payload := map[string]any{
"session_id": sessionID,
"slug": p.Slug,
"chapter": p.Chapter,
}
filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, p.Slug)
items, err := s.pb.listAll(ctx, "progress", filter, "")
if err != nil && err != ErrNotFound {
return err
}
if len(items) == 0 {
return s.pb.post(ctx, "/api/collections/progress/records", payload, nil)
}
var rec struct {
ID string `json:"id"`
}
json.Unmarshal(items[0], &rec)
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/progress/records/%s", rec.ID), payload)
}
func (s *Store) AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error) {
filter := fmt.Sprintf(`session_id=%q`, sessionID)
items, err := s.pb.listAll(ctx, "progress", filter, "-updated")
if err != nil {
return nil, err
}
result := make([]domain.ReadingProgress, 0, len(items))
for _, raw := range items {
var rec struct {
Slug string `json:"slug"`
Chapter int `json:"chapter"`
UpdatedAt string `json:"updated"`
}
json.Unmarshal(raw, &rec)
t, _ := time.Parse(time.RFC3339, rec.UpdatedAt)
result = append(result, domain.ReadingProgress{Slug: rec.Slug, Chapter: rec.Chapter, UpdatedAt: t})
}
return result, nil
}
func (s *Store) DeleteProgress(ctx context.Context, sessionID, slug string) error {
filter := fmt.Sprintf(`session_id=%q&&slug=%q`, sessionID, slug)
items, err := s.pb.listAll(ctx, "progress", filter, "")
if err != nil || len(items) == 0 {
return nil
}
var rec struct {
ID string `json:"id"`
}
json.Unmarshal(items[0], &rec)
return s.pb.delete(ctx, fmt.Sprintf("/api/collections/progress/records/%s", rec.ID))
}
// ── taskqueue.Producer ────────────────────────────────────────────────────────
func (s *Store) CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error) {
payload := map[string]any{
"kind": kind,
"target_url": targetURL,
"from_chapter": fromChapter,
"to_chapter": toChapter,
"status": string(domain.TaskStatusPending),
"started": time.Now().UTC().Format(time.RFC3339),
}
var rec struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/scraping_tasks/records", payload, &rec); err != nil {
return "", err
}
return rec.ID, nil
}
func (s *Store) CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error) {
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice)
payload := map[string]any{
"cache_key": cacheKey,
"slug": slug,
"chapter": chapter,
"voice": voice,
"status": string(domain.TaskStatusPending),
"started": time.Now().UTC().Format(time.RFC3339),
}
var rec struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/audio_jobs/records", payload, &rec); err != nil {
return "", err
}
return rec.ID, nil
}
func (s *Store) CancelTask(ctx context.Context, id string) error {
// Try scraping_tasks first, then audio_jobs.
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id),
map[string]string{"status": string(domain.TaskStatusCancelled)})
}
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
raw, err := s.pb.claimRecord(ctx, "scraping_tasks", workerID, nil)
if err != nil {
return domain.ScrapeTask{}, false, err
}
if raw == nil {
return domain.ScrapeTask{}, false, nil
}
task, err := parseScrapeTask(raw)
return task, err == nil, err
}
func (s *Store) ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error) {
raw, err := s.pb.claimRecord(ctx, "audio_jobs", workerID, nil)
if err != nil {
return domain.AudioTask{}, false, err
}
if raw == nil {
return domain.AudioTask{}, false, nil
}
task, err := parseAudioTask(raw)
return task, err == nil, err
}
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
status = string(domain.TaskStatusFailed)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), map[string]any{
"status": status,
"books_found": result.BooksFound,
"chapters_scraped": result.ChaptersScraped,
"chapters_skipped": result.ChaptersSkipped,
"errors": result.Errors,
"error_message": result.ErrorMessage,
"finished": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Store) FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
status = string(domain.TaskStatusFailed)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), map[string]any{
"status": status,
"error_message": result.ErrorMessage,
"finished": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
payload := map[string]any{
"status": string(domain.TaskStatusFailed),
"error_message": errMsg,
"finished": time.Now().UTC().Format(time.RFC3339),
}
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload)
}
// HeartbeatTask updates the heartbeat_at field on a running task.
// Tries scraping_tasks first, then audio_jobs (same pattern as FailTask).
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
payload := map[string]any{
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
}
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload)
}
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
// or older than staleAfter, and resets them to pending so they can be
// re-claimed. Returns the number of tasks reaped.
func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
threshold := time.Now().UTC().Add(-staleAfter).Format(time.RFC3339)
// Match tasks that are running AND (heartbeat_at is null OR heartbeat_at < threshold).
// PocketBase datetime fields require `=null` not `=""` in filter expressions.
filter := fmt.Sprintf(`status="running"&&(heartbeat_at=null||heartbeat_at<"%s")`, threshold)
resetPayload := map[string]any{
"status": string(domain.TaskStatusPending),
"worker_id": "",
"heartbeat_at": nil,
}
total := 0
for _, collection := range []string{"scraping_tasks", "audio_jobs"} {
items, err := s.pb.listAll(ctx, collection, filter, "")
if err != nil {
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
}
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
continue
}
path := fmt.Sprintf("/api/collections/%s/records/%s", collection, rec.ID)
if err := s.pb.patch(ctx, path, resetPayload); err != nil {
s.log.Warn("ReapStaleTasks: patch failed", "collection", collection, "id", rec.ID, "err", err)
continue
}
total++
}
}
return total, nil
}
// ── taskqueue.Reader ──────────────────────────────────────────────────────────
func (s *Store) ListScrapeTasks(ctx context.Context) ([]domain.ScrapeTask, error) {
items, err := s.pb.listAll(ctx, "scraping_tasks", "", "-started")
if err != nil {
return nil, err
}
tasks := make([]domain.ScrapeTask, 0, len(items))
for _, raw := range items {
t, err := parseScrapeTask(raw)
if err == nil {
tasks = append(tasks, t)
}
}
return tasks, nil
}
func (s *Store) GetScrapeTask(ctx context.Context, id string) (domain.ScrapeTask, bool, error) {
var raw json.RawMessage
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), &raw); err != nil {
if err == ErrNotFound {
return domain.ScrapeTask{}, false, nil
}
return domain.ScrapeTask{}, false, err
}
t, err := parseScrapeTask(raw)
return t, err == nil, err
}
func (s *Store) ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error) {
items, err := s.pb.listAll(ctx, "audio_jobs", "", "-started")
if err != nil {
return nil, err
}
tasks := make([]domain.AudioTask, 0, len(items))
for _, raw := range items {
t, err := parseAudioTask(raw)
if err == nil {
tasks = append(tasks, t)
}
}
return tasks, nil
}
func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) {
filter := fmt.Sprintf(`cache_key=%q`, cacheKey)
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started")
if err != nil || len(items) == 0 {
return domain.AudioTask{}, false, err
}
t, err := parseAudioTask(items[0])
return t, err == nil, err
}
// ── Parsers ───────────────────────────────────────────────────────────────────
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
var rec struct {
ID string `json:"id"`
Kind string `json:"kind"`
TargetURL string `json:"target_url"`
FromChapter int `json:"from_chapter"`
ToChapter int `json:"to_chapter"`
WorkerID string `json:"worker_id"`
Status string `json:"status"`
BooksFound int `json:"books_found"`
ChaptersScraped int `json:"chapters_scraped"`
ChaptersSkipped int `json:"chapters_skipped"`
Errors int `json:"errors"`
Started string `json:"started"`
Finished string `json:"finished"`
ErrorMessage string `json:"error_message"`
}
if err := json.Unmarshal(raw, &rec); err != nil {
return domain.ScrapeTask{}, err
}
started, _ := time.Parse(time.RFC3339, rec.Started)
finished, _ := time.Parse(time.RFC3339, rec.Finished)
return domain.ScrapeTask{
ID: rec.ID,
Kind: rec.Kind,
TargetURL: rec.TargetURL,
FromChapter: rec.FromChapter,
ToChapter: rec.ToChapter,
WorkerID: rec.WorkerID,
Status: domain.TaskStatus(rec.Status),
BooksFound: rec.BooksFound,
ChaptersScraped: rec.ChaptersScraped,
ChaptersSkipped: rec.ChaptersSkipped,
Errors: rec.Errors,
Started: started,
Finished: finished,
ErrorMessage: rec.ErrorMessage,
}, nil
}
func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
var rec struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"`
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
WorkerID string `json:"worker_id"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
}
if err := json.Unmarshal(raw, &rec); err != nil {
return domain.AudioTask{}, err
}
started, _ := time.Parse(time.RFC3339, rec.Started)
finished, _ := time.Parse(time.RFC3339, rec.Finished)
return domain.AudioTask{
ID: rec.ID,
CacheKey: rec.CacheKey,
Slug: rec.Slug,
Chapter: rec.Chapter,
Voice: rec.Voice,
WorkerID: rec.WorkerID,
Status: domain.TaskStatus(rec.Status),
ErrorMessage: rec.ErrorMessage,
Started: started,
Finished: finished,
}, nil
}
// ── BrowseStore ────────────────────────────────────────────────────────────────
func (s *Store) PutBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int, data []byte) error {
key := BrowseObjectKey(genre, sort, status, novelType, page)
if err := s.mc.putBrowse(ctx, key, data); err != nil {
return fmt.Errorf("PutBrowsePage: %w", err)
}
return nil
}
func (s *Store) GetBrowsePage(ctx context.Context, genre, sort, status, novelType string, page int) ([]byte, bool, error) {
key := BrowseObjectKey(genre, sort, status, novelType, page)
data, ok, err := s.mc.getBrowse(ctx, key)
if err != nil {
return nil, false, fmt.Errorf("GetBrowsePage: %w", err)
}
return data, ok, nil
}
// ── CoverStore ─────────────────────────────────────────────────────────────────
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
key := CoverObjectKey(slug)
if contentType == "" {
contentType = coverContentType(data)
}
if err := s.mc.putCover(ctx, key, contentType, data); err != nil {
return fmt.Errorf("PutCover: %w", err)
}
return nil
}
func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) {
key := CoverObjectKey(slug)
data, ok, err := s.mc.getCover(ctx, key)
if err != nil {
return nil, "", false, fmt.Errorf("GetCover: %w", err)
}
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))
}

View File

@@ -0,0 +1,84 @@
// Package taskqueue defines the interfaces for creating and consuming
// scrape/audio tasks stored in PocketBase.
//
// Interface segregation:
// - Producer is used only by the backend (creates tasks, cancels tasks).
// - Consumer is used only by the runner (claims tasks, reports results).
// - Reader is used by the backend for status/history endpoints.
//
// Concrete implementations live in internal/storage.
package taskqueue
import (
"context"
"time"
"github.com/libnovel/backend/internal/domain"
)
// Producer is the write side of the task queue used by the backend service.
// It creates new tasks in PocketBase for the runner to pick up.
type Producer interface {
// CreateScrapeTask inserts a new scrape task with status=pending and
// returns the assigned PocketBase record ID.
// kind is one of "catalogue", "book", or "book_range".
// targetURL is the book URL (empty for catalogue-wide tasks).
CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error)
// CreateAudioTask inserts a new audio task with status=pending and
// returns the assigned PocketBase record ID.
CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error)
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.
CancelTask(ctx context.Context, id string) error
}
// Consumer is the read/claim side of the task queue used by the runner.
type Consumer interface {
// ClaimNextScrapeTask atomically finds the oldest pending scrape task,
// sets its status=running and worker_id=workerID, and returns it.
// Returns (zero, false, nil) when the queue is empty.
ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error)
// ClaimNextAudioTask atomically finds the oldest pending audio task,
// sets its status=running and worker_id=workerID, and returns it.
// Returns (zero, false, nil) when the queue is empty.
ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error)
// FinishScrapeTask marks a running scrape task as done and records the result.
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
// FinishAudioTask marks a running audio task as done and records the result.
FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error
// FailTask marks a task (scrape or audio) as failed with an error message.
FailTask(ctx context.Context, id, errMsg string) error
// HeartbeatTask updates the heartbeat_at timestamp on a running task.
// Should be called periodically by the runner while the task is active so
// the reaper knows the task is still alive.
HeartbeatTask(ctx context.Context, id string) error
// ReapStaleTasks finds all running tasks whose heartbeat_at is older than
// staleAfter (or was never set) and resets them to pending so they can be
// re-claimed by a healthy runner. Returns the number of tasks reaped.
ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error)
}
// Reader is the read-only side used by the backend for status pages.
type Reader interface {
// ListScrapeTasks returns all scrape tasks sorted by started descending.
ListScrapeTasks(ctx context.Context) ([]domain.ScrapeTask, error)
// GetScrapeTask returns a single scrape task by ID.
// Returns (zero, false, nil) if not found.
GetScrapeTask(ctx context.Context, id string) (domain.ScrapeTask, bool, error)
// ListAudioTasks returns all audio tasks sorted by started descending.
ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error)
// GetAudioTask returns the most recent audio task for cacheKey.
// Returns (zero, false, nil) if not found.
GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error)
}

View File

@@ -0,0 +1,138 @@
package taskqueue_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/taskqueue"
)
// ── Compile-time interface satisfaction ───────────────────────────────────────
// stubStore satisfies all three taskqueue interfaces.
// Any method that is called but not expected panics — making accidental
// calls immediately visible in tests.
type stubStore struct{}
func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (string, error) {
return "task-1", nil
}
func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "audio-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
return nil
}
func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error {
return nil
}
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) {
return 0, nil
}
func (s *stubStore) ListScrapeTasks(_ context.Context) ([]domain.ScrapeTask, error) { return nil, nil }
func (s *stubStore) GetScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
return domain.ScrapeTask{}, false, nil
}
func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error) { return nil, nil }
func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
return domain.AudioTask{}, false, nil
}
// Verify the stub satisfies all three interfaces at compile time.
var _ taskqueue.Producer = (*stubStore)(nil)
var _ taskqueue.Consumer = (*stubStore)(nil)
var _ taskqueue.Reader = (*stubStore)(nil)
// ── Behavioural tests (using stub) ────────────────────────────────────────────
func TestProducer_CreateScrapeTask(t *testing.T) {
var p taskqueue.Producer = &stubStore{}
id, err := p.CreateScrapeTask(context.Background(), "book", "https://example.com/book/slug", 0, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id == "" {
t.Error("expected non-empty task ID")
}
}
func TestConsumer_ClaimNextScrapeTask(t *testing.T) {
var c taskqueue.Consumer = &stubStore{}
task, ok, err := c.ClaimNextScrapeTask(context.Background(), "worker-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatal("expected a task to be claimed")
}
if task.Status != domain.TaskStatusRunning {
t.Errorf("want running, got %q", task.Status)
}
}
func TestConsumer_ClaimNextAudioTask(t *testing.T) {
var c taskqueue.Consumer = &stubStore{}
task, ok, err := c.ClaimNextAudioTask(context.Background(), "worker-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatal("expected an audio task to be claimed")
}
if task.ID == "" {
t.Error("expected non-empty task ID")
}
}
// ── domain.ScrapeResult / domain.AudioResult JSON shape ──────────────────────
func TestScrapeResult_JSONRoundtrip(t *testing.T) {
cases := []domain.ScrapeResult{
{BooksFound: 5, ChaptersScraped: 100, ChaptersSkipped: 2, Errors: 0},
{BooksFound: 0, ChaptersScraped: 0, Errors: 1, ErrorMessage: "timeout"},
}
for _, orig := range cases {
b, err := json.Marshal(orig)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got domain.ScrapeResult
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got != orig {
t.Errorf("want %+v, got %+v", orig, got)
}
}
}
func TestAudioResult_JSONRoundtrip(t *testing.T) {
cases := []domain.AudioResult{
{ObjectKey: "audio/slug/1/af_bella.mp3"},
{ErrorMessage: "kokoro unavailable"},
}
for _, orig := range cases {
b, _ := json.Marshal(orig)
var got domain.AudioResult
json.Unmarshal(b, &got)
if got != orig {
t.Errorf("want %+v, got %+v", orig, got)
}
}
}