Files
libnovel/backend/internal/backend/handlers.go
Admin 98e4a87432 feat(tts): dual-engine voice list (kokoro + pocket-tts)
Expose all available voices from both TTS engines via the /api/voices
endpoint. AudioPlayer and profile voice-selector now group voices by
engine and show a labelled optgroup. Voice type carries an engine field
so the chapter-reader can route synthesis to the correct backend.
2026-03-28 14:32:06 +05:00

1193 lines
41 KiB
Go

package backend
// handlers.go — all HTTP request handlers for the backend server.
//
// Handler naming mirrors the route table in server.go:
// handleScrapeCatalogue, handleScrapeBook, handleScrapeBookRange
// handleScrapeStatus, handleScrapeTasks
// handleBrowse, handleSearch
// handleGetRanking, handleGetCover
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
// handleVoices
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
// handlePresignAvatarUpload, handlePresignAvatar
// handleGetProgress, handleSetProgress, handleDeleteProgress
//
// Key design choices vs. old scraper:
// - POST /scrape* creates a PocketBase task record and returns 202 with the
// task_id — it does NOT run the orchestrator inline.
// - POST /api/audio creates a PocketBase audio task and returns 202 — the
// runner binary executes TTS generation asynchronously.
// - GET /api/audio/status polls PocketBase for the task record status.
// - GET /api/audio-proxy reads the completed audio object from MinIO via a
// presigned URL redirect (the runner has already uploaded the bytes).
// - GET /api/browse and /api/search fetch novelfire.net live (no MinIO cache).
// - GET /api/cover redirects to the source cover URL live.
// - GET /api/ranking reads from the PocketBase ranking collection (populated
// by the runner after each catalogue scrape).
// - GET /api/book-preview returns stored data when in library, or enqueues a
// scrape task and returns 202 when not. The backend never scrapes directly.
// - GET /api/chapter-text-preview scrapes a chapter live from novelfire.net
// directly (no runner task, no store writes). Used for unscraped books.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/novelfire/htmlutil"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper"
)
const (
novelFireBase = "https://novelfire.net"
novelFireDomain = "novelfire.net"
)
// ── Scrape task creation ───────────────────────────────────────────────────────
// handleScrapeCatalogue handles POST /scrape.
// Creates a "catalogue" scrape task in PocketBase and returns 202 with the task ID.
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {
taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "catalogue", "", 0, 0)
if err != nil {
s.deps.Log.Error("handleScrapeCatalogue: CreateScrapeTask failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create task")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
}
// handleScrapeBook handles POST /scrape/book.
// Body: {"url": "https://novelfire.net/book/..."}
func (s *Server) handleScrapeBook(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`)
return
}
taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book", body.URL, 0, 0)
if err != nil {
s.deps.Log.Error("handleScrapeBook: CreateScrapeTask failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create task")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
}
// handleScrapeBookRange handles POST /scrape/book/range.
// Body: {"url": "...", "from": N, "to": M}
func (s *Server) handleScrapeBookRange(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
From int `json:"from"`
To int `json:"to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" {
jsonError(w, http.StatusBadRequest, `request body must be JSON with "url" field`)
return
}
taskID, err := s.deps.Producer.CreateScrapeTask(r.Context(), "book_range", body.URL, body.From, body.To)
if err != nil {
s.deps.Log.Error("handleScrapeBookRange: CreateScrapeTask failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create task")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"task_id": taskID, "status": "accepted"})
}
// handleCancelTask handles POST /api/cancel-task/{id}.
// Transitions a pending task (scrape or audio) to status=cancelled.
// Returns 404 if the task does not exist, 409 if it cannot be cancelled
// (e.g. already running/done).
func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
jsonError(w, http.StatusBadRequest, "missing task id")
return
}
if err := s.deps.Producer.CancelTask(r.Context(), id); err != nil {
s.deps.Log.Warn("handleCancelTask: CancelTask failed", "id", id, "err", err)
jsonError(w, http.StatusConflict, "could not cancel task: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled", "id": id})
}
// ── Scrape task status / history ───────────────────────────────────────────────
// handleScrapeStatus handles GET /api/scrape/status.
// Returns the most recent scrape task status (or {"running":false} if none).
func (s *Server) handleScrapeStatus(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context())
if err != nil {
s.deps.Log.Error("handleScrapeStatus: ListScrapeTasks failed", "err", err)
writeJSON(w, 0, map[string]bool{"running": false})
return
}
running := false
for _, t := range tasks {
if t.Status == domain.TaskStatusRunning || t.Status == domain.TaskStatusPending {
running = true
break
}
}
writeJSON(w, 0, map[string]bool{"running": running})
}
// handleScrapeTasks handles GET /api/scrape/tasks.
// Returns all scrape task records from PocketBase, newest first.
func (s *Server) handleScrapeTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListScrapeTasks(r.Context())
if err != nil {
s.deps.Log.Error("handleScrapeTasks: ListScrapeTasks failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list tasks")
return
}
if tasks == nil {
tasks = []domain.ScrapeTask{}
}
writeJSON(w, 0, tasks)
}
// ── Browse & search ────────────────────────────────────────────────────────────
// NovelListing represents a single novel entry from the novelfire browse/search page.
type NovelListing struct {
Slug string `json:"slug"`
Title string `json:"title"`
Cover string `json:"cover"`
Rank string `json:"rank"`
Rating string `json:"rating"`
Chapters string `json:"chapters"`
URL string `json:"url"`
}
// handleSearch handles GET /api/search.
// Query params: q (min 2 chars), source ("local"|"remote"|"all", default "all")
//
// Local search is powered by Meilisearch when configured; falls back to a
// substring match against PocketBase book records otherwise.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if len([]rune(q)) < 2 {
jsonError(w, http.StatusBadRequest, "query must be at least 2 characters")
return
}
source := r.URL.Query().Get("source")
if source == "" {
source = "all"
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var localResults, remoteResults []NovelListing
// Local search: Meilisearch → PocketBase substring fallback
if source == "local" || source == "all" {
meiliBooks, meiliErr := s.deps.SearchIndex.Search(ctx, q, 50)
if meiliErr == nil && len(meiliBooks) > 0 {
for _, b := range meiliBooks {
localResults = append(localResults, NovelListing{
Slug: b.Slug,
Title: b.Title,
Cover: b.Cover,
URL: b.SourceURL,
})
}
} else {
// Fallback: substring match against PocketBase
books, err := s.deps.BookReader.ListBooks(ctx)
if err != nil {
s.deps.Log.Warn("search: ListBooks failed", "err", err)
} else {
qLower := strings.ToLower(q)
for _, b := range books {
if strings.Contains(strings.ToLower(b.Title), qLower) ||
strings.Contains(strings.ToLower(b.Author), qLower) {
localResults = append(localResults, NovelListing{
Slug: b.Slug,
Title: b.Title,
Cover: b.Cover,
URL: b.SourceURL,
})
}
}
}
}
}
// Remote search (novelfire.net)
if source == "remote" || source == "all" {
searchURL := novelFireBase + "/search?keyword=" + url.QueryEscape(q)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err == nil {
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
if resp, fetchErr := http.DefaultClient.Do(req); fetchErr == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
parsed, _ := parseBrowsePage(resp.Body)
remoteResults = parsed
}
}
}
}
// Merge: local first, de-duplicate remote
localSlugs := make(map[string]bool, len(localResults))
for _, item := range localResults {
localSlugs[item.Slug] = true
}
combined := make([]NovelListing, 0, len(localResults)+len(remoteResults))
combined = append(combined, localResults...)
for _, item := range remoteResults {
if !localSlugs[item.Slug] {
combined = append(combined, item)
}
}
writeJSON(w, 0, map[string]any{
"results": combined,
"local_count": len(localResults),
"remote_count": len(remoteResults),
})
}
// ── Ranking ────────────────────────────────────────────────────────────────────
// handleGetRanking handles GET /api/ranking.
// Returns all ranking items sorted by rank ascending.
func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
items, err := s.deps.RankingStore.ReadRankingItems(r.Context())
if err != nil {
s.deps.Log.Error("handleGetRanking: ReadRankingItems failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to read ranking")
return
}
if items == nil {
items = []domain.RankingItem{}
}
writeJSON(w, 0, items)
}
// handleGetCover handles GET /api/cover/{domain}/{slug}.
// Serves the cover image directly from MinIO when available; falls back to a
// redirect to the novelfire CDN when the cover has not yet been downloaded.
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.Error(w, "missing slug", http.StatusBadRequest)
return
}
// Fast path: serve from MinIO if the cover has been downloaded.
if s.deps.CoverStore != nil {
data, ct, ok, err := s.deps.CoverStore.GetCover(r.Context(), slug)
if err != nil {
s.deps.Log.Warn("handleGetCover: GetCover error", "slug", slug, "err", err)
}
if ok && len(data) > 0 {
if ct == "" {
ct = "image/jpeg"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(data)
return
}
}
// Fallback: redirect to the CDN. The caller sees a working image; the
// cover will be populated on the next catalogue refresh run.
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
http.Redirect(w, r, coverURL, http.StatusFound)
}
// ── Preview (live scrape, no store writes) ─────────────────────────────────────
// handleBookPreview handles GET /api/book-preview/{slug}.
//
// If the book is already in the library (PocketBase), returns its metadata and
// chapter index immediately (200).
//
// If the book is not yet in the library, enqueues a "book" scrape task and
// returns 202 Accepted with the task_id. The runner will scrape the book
// asynchronously; the client should poll GET /api/scrape/status or
// GET /api/scrape/tasks to detect completion, then re-request this endpoint.
//
// The backend never scrapes directly — all scraping is the runner's job.
func (s *Server) handleBookPreview(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
if err != nil {
s.deps.Log.Warn("book-preview: ReadMetadata failed", "slug", slug, "err", err)
inLib = false
}
if inLib {
// Fast path: book is already scraped — return stored data.
chapters, cerr := s.deps.BookReader.ListChapters(ctx, slug)
if cerr != nil {
s.deps.Log.Warn("book-preview: ListChapters failed", "slug", slug, "err", cerr)
}
writeJSON(w, 0, map[string]any{
"in_lib": true,
"meta": meta,
"chapters": chapters,
})
return
}
// Book not in library — enqueue a range scrape task for the first 20 chapters
// so the user can start reading quickly. Remaining chapters can be scraped
// later via the book detail page or the admin scrape panel.
bookURL := r.URL.Query().Get("source_url")
if bookURL == "" {
bookURL = fmt.Sprintf("%s/book/%s", novelFireBase, slug)
}
const previewFrom, previewTo = 1, 20
taskID, err := s.deps.Producer.CreateScrapeTask(ctx, "book_range", bookURL, previewFrom, previewTo)
if err != nil {
s.deps.Log.Error("book-preview: CreateScrapeTask failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to enqueue scrape task")
return
}
s.deps.Log.Info("book-preview: enqueued range scrape task", "slug", slug, "task_id", taskID,
"from", previewFrom, "to", previewTo)
writeJSON(w, http.StatusAccepted, map[string]any{
"in_lib": false,
"task_id": taskID,
"message": fmt.Sprintf("scraping first %d chapters; poll /api/scrape/tasks for completion", previewTo),
})
}
// ── Chapter text ───────────────────────────────────────────────────────────────
// handleChapterText handles GET /api/chapter-text/{slug}/{n}.
// Returns plain text (markdown stripped) of a stored chapter.
func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, stripMarkdown(raw))
}
// handleChapterMarkdown handles GET /api/chapter-markdown/{slug}/{n}.
//
// Returns the raw markdown content of a stored chapter directly from MinIO.
// This is used by the SvelteKit UI as a simpler alternative to presign+fetch:
// it avoids the need for the SvelteKit server to reach MinIO directly, and
// gives a clean 404 when the chapter has not been scraped yet.
func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
if err != nil {
s.deps.Log.Warn("chapter-markdown: not found in MinIO", "slug", slug, "n", n, "err", err)
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, raw)
}
// handleChapterTextPreview handles GET /api/chapter-text-preview/{slug}/{n}.
//
// Fetches a chapter live from novelfire.net and returns its plain text without
// writing anything to PocketBase or MinIO. This is the preview path used when
// a chapter has not yet been scraped into the library.
//
// Optional query params:
//
// chapter_url — the canonical chapter URL (preferred over constructing one)
// title — hint for the chapter title (used when the page title is empty)
//
// Response: {"slug":string,"number":int,"title":string,"text":string,"url":string}
func (s *Server) handleChapterTextPreview(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
jsonError(w, http.StatusBadRequest, "invalid slug or chapter number")
return
}
// Determine the chapter URL to fetch.
chapterURL := r.URL.Query().Get("chapter_url")
if chapterURL == "" {
// Best-effort: novelfire chapter URLs follow /book/{slug}/chapter-{n}
chapterURL = fmt.Sprintf("%s/book/%s/chapter-%d", novelFireBase, slug, n)
}
titleHint := r.URL.Query().Get("title")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Fetch the chapter page.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chapterURL, nil)
if err != nil {
s.deps.Log.Error("chapter-text-preview: build request failed", "url", chapterURL, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to build request")
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; libnovel-backend/2)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := http.DefaultClient.Do(req)
if err != nil {
s.deps.Log.Warn("chapter-text-preview: fetch failed", "url", chapterURL, "err", err)
jsonError(w, http.StatusBadGateway, "failed to fetch chapter")
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
jsonError(w, http.StatusNotFound, "chapter not found")
return
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
s.deps.Log.Warn("chapter-text-preview: upstream error",
"url", chapterURL, "status", resp.StatusCode, "body_snippet", string(body))
jsonError(w, http.StatusBadGateway, fmt.Sprintf("upstream returned %d", resp.StatusCode))
return
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
s.deps.Log.Error("chapter-text-preview: read body failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to read response")
return
}
// Parse HTML and extract the #content node.
root, err := htmlutil.ParseHTML(string(bodyBytes))
if err != nil {
s.deps.Log.Error("chapter-text-preview: html parse failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to parse chapter HTML")
return
}
container := htmlutil.FindFirst(root, scraper.Selector{ID: "content"})
if container == nil {
s.deps.Log.Warn("chapter-text-preview: #content not found", "url", chapterURL)
jsonError(w, http.StatusNotFound, "chapter content not found on page")
return
}
markdownText := htmlutil.NodeToMarkdown(container)
plainText := stripMarkdown(markdownText)
// Extract the chapter title from the page <title> or <h1> if not hinted.
chapterTitle := titleHint
if chapterTitle == "" {
// Try <h1 class="chapter-title"> first, then <h2 class="chapter-title">
for _, tag := range []string{"h1", "h2", "h3"} {
if node := htmlutil.FindFirst(root, scraper.Selector{Tag: tag, Class: "chapter-title"}); node != nil {
chapterTitle = strings.TrimSpace(htmlutil.TextContent(node))
break
}
}
}
if chapterTitle == "" {
chapterTitle = fmt.Sprintf("Chapter %d", n)
}
writeJSON(w, 0, map[string]any{
"slug": slug,
"number": n,
"title": chapterTitle,
"text": plainText,
"url": chapterURL,
})
}
// handleReindex handles POST /api/reindex/{slug}.
// Rebuilds the chapters_idx PocketBase collection for a book from MinIO objects.
func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
count, err := s.deps.BookReader.ReindexChapters(r.Context(), slug)
if err != nil {
s.deps.Log.Error("reindex failed", "slug", slug, "indexed", count, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
"indexed": count,
})
return
}
s.deps.Log.Info("reindex complete", "slug", slug, "indexed", count)
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
}
// ── Audio ──────────────────────────────────────────────────────────────────────
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
// Creates an audio_jobs task in PocketBase (runner executes asynchronously).
// Returns 200 immediately if audio already exists in MinIO.
// Returns 202 with the task_id if a new task was created.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
jsonError(w, http.StatusBadRequest, "invalid chapter")
return
}
voice := s.cfg.DefaultVoice
var body struct {
Voice string `json:"voice"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
if body.Voice != "" {
voice = body.Voice
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
// Fast path: audio already in MinIO
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
writeJSON(w, 0, map[string]string{"url": proxyURL, "status": "done"})
return
}
// Check if a task is already pending/running
task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) {
writeJSON(w, http.StatusAccepted, map[string]string{
"task_id": task.ID,
"status": string(task.Status),
})
return
}
// Create a new audio task
taskID, err := s.deps.Producer.CreateAudioTask(r.Context(), slug, n, voice)
if err != nil {
s.deps.Log.Error("handleAudioGenerate: CreateAudioTask failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create audio task")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{
"task_id": taskID,
"status": "pending",
})
}
// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
// Polls PocketBase for the audio task status.
// Query params: voice (optional, defaults to DefaultVoice)
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
jsonError(w, http.StatusBadRequest, "invalid params")
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
// Fast path: audio exists in MinIO
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
writeJSON(w, 0, map[string]string{
"status": "done",
"url": proxyURL,
})
return
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
task, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
if !found {
writeJSON(w, 0, map[string]string{"status": "idle"})
return
}
resp := map[string]string{
"status": string(task.Status),
"task_id": task.ID,
}
if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" {
resp["error"] = task.ErrorMessage
}
writeJSON(w, 0, resp)
}
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
// Redirects to a presigned MinIO URL for the generated audio object.
// Query params: voice (optional, defaults to DefaultVoice)
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
if !s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
http.Error(w, "audio not generated yet", http.StatusNotFound)
return
}
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
if err != nil {
s.deps.Log.Error("handleAudioProxy: PresignAudio failed", "slug", slug, "n", n, "err", err)
http.Error(w, "presign failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, presignURL, http.StatusFound)
}
// ── Voices ─────────────────────────────────────────────────────────────────────
// handleVoices handles GET /api/voices.
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
}
// ── Presigned URLs ─────────────────────────────────────────────────────────────
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
jsonError(w, http.StatusBadRequest, "invalid params")
return
}
u, err := s.deps.PresignStore.PresignChapter(r.Context(), slug, n, 15*time.Minute)
if err != nil {
s.deps.Log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
writeJSON(w, 0, map[string]string{"url": u})
}
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
// Query params: voice (optional)
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
jsonError(w, http.StatusBadRequest, "invalid params")
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
key := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
http.NotFound(w, r)
return
}
u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
if err != nil {
s.deps.Log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
writeJSON(w, 0, map[string]string{"url": u})
}
// voiceSampleText is the phrase synthesised for every voice sample.
const voiceSampleText = "Hello! This is a preview of what I sound like. I hope you enjoy listening to your stories with my voice."
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
// If the sample has not been generated yet it synthesises it on the fly via
// the appropriate TTS engine (Kokoro for kokoro voices, pocket-tts for
// pocket-tts voices), stores the result in MinIO, and returns the presigned URL.
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
voice := r.PathValue("voice")
if voice == "" {
jsonError(w, http.StatusBadRequest, "missing voice")
return
}
key := kokoro.VoiceSampleKey(voice)
// Generate sample on demand when it is not in MinIO yet.
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
s.deps.Log.Info("generating voice sample on demand", "voice", voice)
var (
mp3 []byte
err error
)
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
mp3, err = s.deps.PocketTTS.GenerateAudio(r.Context(), voiceSampleText, voice)
} else {
mp3, err = s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
}
if err != nil {
s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "voice sample generation failed")
return
}
if err := s.deps.AudioStore.PutAudio(r.Context(), key, mp3); err != nil {
s.deps.Log.Error("voice sample upload failed", "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "voice sample upload failed")
return
}
}
u, err := s.deps.PresignStore.PresignAudio(r.Context(), key, 1*time.Hour)
if err != nil {
s.deps.Log.Error("presign voice sample failed", "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
writeJSON(w, 0, map[string]string{"url": u})
}
// handleAvatarUpload handles PUT /api/avatar-upload/{userId}.
// The request body must be the raw image bytes; Content-Type must be
// image/jpeg, image/png, or image/webp.
//
// This endpoint is called by the SvelteKit server (not the browser directly),
// so MinIO credentials and internal networking are not a concern.
//
// Returns: { "key": "<objectKey>" }
func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
jsonError(w, http.StatusBadRequest, "missing userId")
return
}
ct := r.Header.Get("Content-Type")
var ext string
switch {
case strings.HasPrefix(ct, "image/jpeg"):
ext = "jpg"
case strings.HasPrefix(ct, "image/png"):
ext = "png"
case strings.HasPrefix(ct, "image/webp"):
ext = "webp"
default:
jsonError(w, http.StatusBadRequest, "unsupported content-type; use image/jpeg, image/png, or image/webp")
return
}
const maxSize = 5 << 20 // 5 MiB
data, err := io.ReadAll(io.LimitReader(r.Body, maxSize+1))
if err != nil {
jsonError(w, http.StatusBadRequest, "failed to read body")
return
}
if len(data) > maxSize {
jsonError(w, http.StatusRequestEntityTooLarge, "image too large (max 5 MiB)")
return
}
if len(data) == 0 {
jsonError(w, http.StatusBadRequest, "empty body")
return
}
key, err := s.deps.PresignStore.PutAvatar(r.Context(), userID, ext, ct, data)
if err != nil {
s.deps.Log.Error("avatar upload failed", "userId", userID, "err", err)
jsonError(w, http.StatusInternalServerError, "upload failed")
return
}
writeJSON(w, 0, map[string]string{"key": key})
}
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
// Query params: ext (jpg|png|webp, defaults to jpg)
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
jsonError(w, http.StatusBadRequest, "missing userId")
return
}
ext := r.URL.Query().Get("ext")
switch ext {
case "jpg", "jpeg":
ext = "jpg"
case "png":
ext = "png"
case "webp":
ext = "webp"
default:
ext = "jpg"
}
uploadURL, key, err := s.deps.PresignStore.PresignAvatarUpload(r.Context(), userID, ext)
if err != nil {
s.deps.Log.Error("presign avatar upload failed", "userId", userID, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
writeJSON(w, 0, map[string]string{"upload_url": uploadURL, "key": key})
}
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
jsonError(w, http.StatusBadRequest, "missing userId")
return
}
u, found, err := s.deps.PresignStore.PresignAvatarURL(r.Context(), userID)
if err != nil {
s.deps.Log.Error("presign avatar failed", "userId", userID, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
if !found {
http.NotFound(w, r)
return
}
writeJSON(w, 0, map[string]string{"url": u})
}
// ── Progress ───────────────────────────────────────────────────────────────────
// handleGetProgress handles GET /api/progress.
// Returns {"slug": chapterNum, "slug_ts": timestampMs, ...}
func (s *Server) handleGetProgress(w http.ResponseWriter, r *http.Request) {
sid := ensureSession(w, r)
entries, err := s.deps.ProgressStore.AllProgress(r.Context(), sid)
if err != nil {
s.deps.Log.Error("AllProgress failed", "err", err)
entries = nil
}
progress := make(map[string]any, len(entries)*2)
for _, p := range entries {
progress[p.Slug] = p.Chapter
progress[p.Slug+"_ts"] = p.UpdatedAt.UnixMilli()
}
writeJSON(w, 0, progress)
}
// handleSetProgress handles POST /api/progress/{slug}.
// Body: {"chapter": N}
func (s *Server) handleSetProgress(w http.ResponseWriter, r *http.Request) {
sid := ensureSession(w, r)
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
var body struct {
Chapter int `json:"chapter"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Chapter < 1 {
jsonError(w, http.StatusBadRequest, "invalid body")
return
}
p := domain.ReadingProgress{
Slug: slug,
Chapter: body.Chapter,
UpdatedAt: time.Now(),
}
if err := s.deps.ProgressStore.SetProgress(r.Context(), sid, p); err != nil {
s.deps.Log.Error("SetProgress failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "store error")
return
}
writeJSON(w, 0, map[string]string{})
}
// handleDeleteProgress handles DELETE /api/progress/{slug}.
func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) {
sid := ensureSession(w, r)
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
if err := s.deps.ProgressStore.DeleteProgress(r.Context(), sid, slug); err != nil {
s.deps.Log.Error("DeleteProgress failed", "slug", slug, "err", err)
// non-fatal
}
writeJSON(w, 0, map[string]string{})
}
// ── Catalogue (Meilisearch-backed browse + search) ────────────────────────────
// handleCatalogue handles GET /api/catalogue.
//
// Provides unified browse + search over the locally-indexed book catalogue
// via Meilisearch. Unlike /api/browse this never fetches novelfire.net live —
// it is entirely served from the Meilisearch index populated by the runner.
//
// Query params:
//
// q — full-text search query (optional)
// genre — genre filter, e.g. "fantasy" or "all" (default "all")
// status — status filter: "ongoing", "completed", or "all" (default "all")
// sort — "popular" (default) | "new" | "top-rated" | "rank"
// page — 1-indexed page number (default 1)
// limit — items per page (default 20, max 100)
func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
genre := q.Get("genre")
if genre == "" {
genre = "all"
}
status := q.Get("status")
if status == "" {
status = "all"
}
sort := q.Get("sort")
if sort == "" {
sort = "popular"
}
page, _ := strconv.Atoi(q.Get("page"))
if page <= 0 {
page = 1
}
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
cq := meili.CatalogueQuery{
Q: q.Get("q"),
Genre: genre,
Status: status,
Sort: sort,
Page: page,
Limit: limit,
}
books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
if err != nil {
s.deps.Log.Error("handleCatalogue: Catalogue query failed", "err", err)
jsonError(w, http.StatusInternalServerError, "search failed")
return
}
hasNext := int64(page*limit) < total
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, 0, map[string]any{
"books": books,
"page": page,
"limit": limit,
"total": total,
"has_next": hasNext,
"facets": map[string]any{
"genres": facets.Genres,
"statuses": facets.Statuses,
},
})
}
// ── Browse page parsing helpers ────────────────────────────────────────────────
// fetchBrowsePage fetches pageURL and parses NovelListings from the HTML.
func (s *Server) fetchBrowsePage(ctx context.Context, pageURL string) ([]NovelListing, bool, error) {
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-backend/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")
resp, err := http.DefaultClient.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", resp.StatusCode)
}
novels, hasNext := parseBrowsePage(resp.Body)
return novels, hasNext, nil
}
// parseBrowsePage parses a novelfire HTML body and returns novel listings.
// It uses a simple string-scanning approach to avoid importing golang.org/x/net/html
// in this package (that dependency is only in internal/novelfire).
func parseBrowsePage(r io.Reader) ([]NovelListing, bool) {
data, err := io.ReadAll(r)
if err != nil {
return nil, false
}
body := string(data)
var novels []NovelListing
hasNext := false
// Detect "next page" link
if strings.Contains(body, `rel="next"`) ||
strings.Contains(body, `aria-label="Next"`) ||
strings.Contains(body, `class="next"`) {
hasNext = true
}
// Extract novel slugs and titles using simple regex patterns.
// novelfire.net novel items: <li class="novel-item">...</li>
// Each contains an anchor like <a href="/book/{slug}">
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)
seen := make(map[string]bool)
for i, sm := range slugMatches {
slug := sm[1]
if seen[slug] {
continue
}
seen[slug] = true
novel := NovelListing{
Slug: slug,
URL: novelFireBase + "/book/" + slug,
}
if i < len(titleMatches) {
novel.Title = strings.TrimSpace(titleMatches[i][1])
}
if i < len(coverMatches) {
novel.Cover = coverMatches[i][1]
}
if novel.Title != "" {
novels = append(novels, novel)
}
}
return novels, hasNext
}
// ── Markdown stripping ─────────────────────────────────────────────────────────
// stripMarkdown removes common markdown syntax from src, returning plain text.
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)
}
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
// the Kokoro service is unavailable.
var kokoroVoiceIDs = []string{
// American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
"af_sarah", "af_sky",
"am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam",
"am_michael", "am_onyx", "am_puck",
// British English
"bf_alice", "bf_emma", "bf_lily",
"bm_daniel", "bm_fable", "bm_george", "bm_lewis",
// Spanish
"ef_dora", "em_alex",
// French
"ff_siwis",
// Hindi
"hf_alpha", "hf_beta", "hm_omega", "hm_psi",
// Italian
"if_sara", "im_nicola",
// Japanese
"jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo",
// Portuguese
"pf_dora", "pm_alex",
// Chinese
"zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi",
"zm_yunjian", "zm_yunxi", "zm_yunxia", "zm_yunyang",
}