chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows - Flatten v3/ contents to repo root - Add Doppler secrets management (project=libnovel, config=prd) - Add justfile with doppler run wrappers for all docker compose commands - Strip hardcoded env fallbacks from docker-compose.yml - Add minimal README.md - Clean up .gitignore
This commit is contained in:
@@ -7,8 +7,7 @@ package backend
|
||||
// handleScrapeStatus, handleScrapeTasks
|
||||
// handleBrowse, handleSearch
|
||||
// handleGetRanking, handleGetCover
|
||||
// handleBookPreview, handleChapterText, handleReindex
|
||||
// handleChapterText, handleReindex
|
||||
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
|
||||
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
|
||||
// handleVoices
|
||||
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
|
||||
@@ -29,6 +28,8 @@ package backend
|
||||
// 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"
|
||||
@@ -44,6 +45,9 @@ import (
|
||||
|
||||
"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/scraper"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -172,82 +176,11 @@ type NovelListing struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleBrowse handles GET /api/browse.
|
||||
// Fetches novelfire.net live (no MinIO cache in the new backend).
|
||||
// Query params: page (default 1), genre (default "all"), sort (default "popular"),
|
||||
// status (default "all"), type (default "all-novel")
|
||||
func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := q.Get("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
genre := q.Get("genre")
|
||||
if genre == "" {
|
||||
genre = "all"
|
||||
}
|
||||
sortBy := q.Get("sort")
|
||||
if sortBy == "" {
|
||||
sortBy = "popular"
|
||||
}
|
||||
status := q.Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
novelType := q.Get("type")
|
||||
if novelType == "" {
|
||||
novelType = "all-novel"
|
||||
}
|
||||
|
||||
pageNum, _ := strconv.Atoi(page)
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
|
||||
// ── Try MinIO cache first ─────────────────────────────────────────────
|
||||
// Only page 1 is cached; higher pages fall through to live fetch.
|
||||
if pageNum == 1 && s.deps.BrowseStore != nil {
|
||||
if data, ok, err := s.deps.BrowseStore.GetBrowsePage(r.Context(), genre, sortBy, status, novelType, 1); err == nil && ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fall back to live novelfire.net fetch ──────────────────────────────
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
targetURL := fmt.Sprintf("%s/genre-%s/sort-%s/status-%s/%s?page=%d",
|
||||
novelFireBase, genre, sortBy, status, novelType, pageNum)
|
||||
|
||||
novels, hasNext, err := s.fetchBrowsePage(ctx, targetURL)
|
||||
if err != nil {
|
||||
// Live fetch also failed — return empty list with cached=false flag so
|
||||
// the UI can show a "not ready yet" state instead of a hard error.
|
||||
s.deps.Log.Error("handleBrowse: fetch failed (no cache)", "url", targetURL, "err", err)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"novels": []any{},
|
||||
"page": pageNum,
|
||||
"hasNext": false,
|
||||
"cached": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"novels": novels,
|
||||
"page": pageNum,
|
||||
"hasNext": hasNext,
|
||||
"cached": false,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -265,22 +198,35 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var localResults, remoteResults []NovelListing
|
||||
|
||||
// Local search (PocketBase books)
|
||||
// Local search: Meilisearch → PocketBase substring fallback
|
||||
if source == "local" || source == "all" {
|
||||
books, err := s.deps.BookReader.ListBooks(ctx)
|
||||
if err != nil {
|
||||
s.deps.Log.Warn("search: ListBooks failed", "err", err)
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,18 +287,34 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// handleGetCover handles GET /api/cover/{domain}/{slug}.
|
||||
// The new backend does not cache covers in MinIO. Instead it redirects the
|
||||
// client to the novelfire.net source URL. The domain path segment is kept for
|
||||
// API compatibility with the old scraper.
|
||||
// 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
|
||||
}
|
||||
// Redirect to the standard novelfire cover CDN URL. If the caller has the
|
||||
// actual cover URL stored in metadata they should use it directly; this
|
||||
// endpoint is a best-effort fallback.
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -469,6 +431,117 @@ func (s *Server) handleChapterMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
@@ -685,7 +758,13 @@ func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// Kokoro, stores the result in MinIO, and returns the presigned URL — so the
|
||||
// caller always gets a playable URL in a single request.
|
||||
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
||||
voice := r.PathValue("voice")
|
||||
if voice == "" {
|
||||
@@ -694,9 +773,21 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
key := kokoro.VoiceSampleKey(voice)
|
||||
|
||||
// Generate sample on demand when it is not in MinIO yet.
|
||||
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
s.deps.Log.Info("generating voice sample on demand", "voice", voice)
|
||||
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)
|
||||
@@ -708,6 +799,59 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
||||
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) {
|
||||
@@ -826,6 +970,82 @@ func (s *Server) handleDeleteProgress(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// 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)
|
||||
// - Live novelfire.net search (no scraper interface needed; direct HTTP)
|
||||
// - Kokoro voice list
|
||||
//
|
||||
// The backend never scrapes directly. All scraping (metadata, chapter list,
|
||||
@@ -28,8 +28,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
@@ -46,12 +48,16 @@ type Dependencies struct {
|
||||
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
|
||||
@@ -88,6 +94,9 @@ func New(cfg Config, deps Dependencies) *Server {
|
||||
if deps.Log == nil {
|
||||
deps.Log = slog.Default()
|
||||
}
|
||||
if deps.SearchIndex == nil {
|
||||
deps.SearchIndex = meili.NoopClient{}
|
||||
}
|
||||
return &Server{cfg: cfg, deps: deps}
|
||||
}
|
||||
|
||||
@@ -112,10 +121,12 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// 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)
|
||||
// Browse & search
|
||||
mux.HandleFunc("GET /api/search", s.handleSearch)
|
||||
|
||||
// Catalogue (Meilisearch-backed browse + search — preferred path for UI)
|
||||
mux.HandleFunc("GET /api/catalogue", s.handleCatalogue)
|
||||
|
||||
// Ranking (from PocketBase)
|
||||
mux.HandleFunc("GET /api/ranking", s.handleGetRanking)
|
||||
|
||||
@@ -131,6 +142,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Use this instead of presign+fetch to avoid SvelteKit→MinIO network path.
|
||||
mux.HandleFunc("GET /api/chapter-markdown/{slug}/{n}", s.handleChapterMarkdown)
|
||||
|
||||
// Chapter text preview — live scrape from novelfire.net, no store writes.
|
||||
// Used when the chapter is not yet in the library (preview mode).
|
||||
mux.HandleFunc("GET /api/chapter-text-preview/{slug}/{n}", s.handleChapterTextPreview)
|
||||
|
||||
// Reindex chapters_idx from MinIO
|
||||
mux.HandleFunc("POST /api/reindex/{slug}", s.handleReindex)
|
||||
|
||||
@@ -148,6 +163,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
|
||||
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||
|
||||
// Reading progress
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
@@ -156,7 +172,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.cfg.Addr,
|
||||
Handler: mux,
|
||||
Handler: sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle(mux),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
|
||||
Reference in New Issue
Block a user