steps 6-8: wire HybridStore into orchestrator, server, and main
- Add storage/hybrid.go: HybridStore composing PocketBase + MinIO backends - Rewrite orchestrator to accept storage.Store instead of *writer.Writer - Replace *writer.Writer with storage.Store in server.go and ui.go - Wire audio cache, reading progress, chapter reads/writes through store - Add rankingCacheAdapter in main.go to bridge context-free RankingPageCacher interface to HybridStore's context-aware methods
This commit is contained in:
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
"github.com/libnovel/scraper/internal/orchestrator"
|
||||
"github.com/libnovel/scraper/internal/scraper"
|
||||
"github.com/libnovel/scraper/internal/writer"
|
||||
"github.com/libnovel/scraper/internal/storage"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
goldhtml "github.com/yuin/goldmark/renderer/html"
|
||||
@@ -629,7 +629,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
books, err := s.writer.ListBooks()
|
||||
books, err := s.store.ListBooks(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to list books: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -639,8 +639,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
for i, b := range books {
|
||||
items[i] = homeBookItem{
|
||||
BookMeta: b,
|
||||
Downloaded: s.writer.CountChapters(b.Slug),
|
||||
AddedAt: s.writer.MetadataMtime(b.Slug),
|
||||
Downloaded: s.store.CountChapters(r.Context(), b.Slug),
|
||||
AddedAt: s.store.MetadataMtime(r.Context(), b.Slug),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ const scrapeTmpl = `
|
||||
</script>`
|
||||
|
||||
func (s *Server) handleScrape(w http.ResponseWriter, r *http.Request) {
|
||||
rankingItems, _ := s.writer.ReadRankingItems()
|
||||
rankingItems, _ := s.store.ReadRankingItems(r.Context())
|
||||
rankingJSON, _ := json.Marshal(rankingItems)
|
||||
|
||||
t := template.Must(template.New("scrape").Parse(scrapeTmpl))
|
||||
@@ -1324,12 +1324,12 @@ const rankingTmpl = `
|
||||
// rankingViewItem enriches a RankingItem with whether it is present in the
|
||||
// local book library, so the template can highlight it differently.
|
||||
type rankingViewItem struct {
|
||||
writer.RankingItem
|
||||
storage.RankingItem
|
||||
Local bool
|
||||
}
|
||||
|
||||
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
|
||||
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
|
||||
func toRankingViewItems(items []storage.RankingItem, localSlugs map[string]bool) []rankingViewItem {
|
||||
out := make([]rankingViewItem, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = rankingViewItem{
|
||||
@@ -1403,13 +1403,13 @@ const rankingPageSize = 20
|
||||
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
|
||||
// Supports ?page=N for browsing through cached items (20 per page).
|
||||
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
||||
rankingItems, err := s.writer.ReadRankingItems()
|
||||
rankingItems, err := s.store.ReadRankingItems(r.Context())
|
||||
if err != nil {
|
||||
s.log.Error("failed to read cached ranking", "err", err)
|
||||
}
|
||||
|
||||
cachedAt := ""
|
||||
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
|
||||
if info, statErr := s.store.RankingFileInfo(r.Context()); statErr == nil && info != nil {
|
||||
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
|
||||
}
|
||||
|
||||
@@ -1482,7 +1482,7 @@ func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Encode full dataset + local slugs for client-side cross-page filtering.
|
||||
localSlugs := s.writer.LocalSlugs()
|
||||
localSlugs, _ := s.store.LocalSlugs(r.Context())
|
||||
type rankingJSONItem struct {
|
||||
Rank int `json:"rank"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -1577,14 +1577,14 @@ func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages)
|
||||
|
||||
var rankingItems []writer.RankingItem
|
||||
var rankingItems []storage.RankingItem
|
||||
for rankingCh != nil || errCh != nil {
|
||||
select {
|
||||
case meta, ok := <-rankingCh:
|
||||
if !ok {
|
||||
rankingCh = nil
|
||||
} else {
|
||||
rankingItems = append(rankingItems, writer.RankingItem{
|
||||
rankingItems = append(rankingItems, storage.RankingItem{
|
||||
Rank: meta.Ranking,
|
||||
Slug: meta.Slug,
|
||||
Title: meta.Title,
|
||||
@@ -1605,7 +1605,7 @@ func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(rankingItems) > 0 {
|
||||
if err := s.writer.WriteRanking(rankingItems); err != nil {
|
||||
if err := s.store.WriteRanking(ctx, rankingItems); err != nil {
|
||||
s.log.Error("failed to save ranking", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -1669,7 +1669,7 @@ const rankingViewTmpl = `
|
||||
</div>`
|
||||
|
||||
func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.writer.ReadRankingItems()
|
||||
items, err := s.store.ReadRankingItems(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read ranking: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1927,7 +1927,7 @@ const bookTmpl = `
|
||||
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
|
||||
meta, ok, err := s.writer.ReadMetadata(slug)
|
||||
meta, ok, err := s.store.ReadMetadata(r.Context(), slug)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read metadata: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1937,7 +1937,7 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
chapters, err := s.writer.ListChapters(slug)
|
||||
chapters, err := s.store.ListChapters(r.Context(), slug)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -2054,7 +2054,7 @@ func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
chapters, err := s.writer.ListChapters(slug)
|
||||
chapters, err := s.store.ListChapters(r.Context(), slug)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -3035,7 +3035,7 @@ func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := s.writer.ReadChapter(slug, n)
|
||||
raw, err := s.store.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -3051,15 +3051,15 @@ func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
chapters, _ := s.writer.ListChapters(slug)
|
||||
chapters, _ := s.store.ListChapters(r.Context(), slug)
|
||||
prevN, nextN := adjacentChapters(chapters, n)
|
||||
|
||||
title := firstHeading(raw, fmt.Sprintf("Chapter %d", n))
|
||||
chapterTitle, chapterDate := writer.SplitChapterTitle(title)
|
||||
chapterTitle, chapterDate := splitChapterTitle(title)
|
||||
|
||||
// Load cover URL for Media Session artwork (best-effort; ignore errors).
|
||||
var coverURL string
|
||||
if meta, ok, err := s.writer.ReadMetadata(slug); err == nil && ok {
|
||||
if meta, ok, err := s.store.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
coverURL = meta.Cover
|
||||
}
|
||||
|
||||
@@ -3096,6 +3096,36 @@ func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// splitChapterTitle splits a raw chapter heading into a human-readable title
|
||||
// and a trailing relative-date string (e.g. "1 year ago"). It mirrors the
|
||||
// same logic in internal/writer and internal/storage/hybrid.
|
||||
func splitChapterTitle(raw string) (title, date string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
// Strip leading numeric index.
|
||||
if idx := strings.IndexFunc(raw, func(r rune) bool { return r == ' ' || r == '\t' }); idx > 0 {
|
||||
prefix := raw[:idx]
|
||||
allDigit := true
|
||||
for _, c := range prefix {
|
||||
if c < '0' || c > '9' {
|
||||
allDigit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigit {
|
||||
raw = strings.TrimSpace(raw[idx:])
|
||||
}
|
||||
}
|
||||
// Strip "Chapter N - N: " prefix.
|
||||
chNumRe := regexp.MustCompile(`(?i)^chapter\s+\d+(?:\s*-\s*\d+)?\s*:\s*`)
|
||||
raw = strings.TrimSpace(chNumRe.ReplaceAllString(raw, ""))
|
||||
// Detect trailing relative date.
|
||||
dateRe := regexp.MustCompile(`\s*(\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)\s*$`)
|
||||
if m := dateRe.FindStringSubmatchIndex(raw); m != nil {
|
||||
return strings.TrimSpace(raw[:m[0]]), strings.TrimSpace(raw[m[2]:m[3]])
|
||||
}
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of a string-bool map in sorted order.
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
@@ -3127,7 +3157,7 @@ func stripMarkdown(src string) string {
|
||||
|
||||
// adjacentChapters returns the chapter numbers immediately before and after n
|
||||
// in the sorted chapters list. 0 means "does not exist".
|
||||
func adjacentChapters(chapters []writer.ChapterInfo, n int) (prev, next int) {
|
||||
func adjacentChapters(chapters []storage.ChapterInfo, n int) (prev, next int) {
|
||||
for i, ch := range chapters {
|
||||
if ch.Number == n {
|
||||
if i > 0 {
|
||||
@@ -3211,7 +3241,7 @@ func (s *Server) handleUIScrapeBook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
|
||||
defer cancel()
|
||||
|
||||
o := orchestrator.New(cfg, s.novel, s.log)
|
||||
o := orchestrator.New(cfg, s.novel, s.log, s.store)
|
||||
if err := o.Run(ctx); err != nil {
|
||||
s.log.Error("UI scrape job failed", "url", bookURL, "err", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user