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:
Admin
2026-03-02 14:44:02 +05:00
parent 9add9033b9
commit 18e76c9668
5 changed files with 443 additions and 87 deletions

View File

@@ -22,7 +22,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"
)
// Server wraps an HTTP mux with the scraping endpoints.
@@ -31,7 +31,7 @@ type Server struct {
oCfg orchestrator.Config
novel scraper.NovelScraper
log *slog.Logger
writer *writer.Writer
store storage.Store
mu sync.Mutex
running bool
rankingRunning bool
@@ -42,26 +42,23 @@ type Server struct {
voiceMu sync.RWMutex
cachedVoices []string // populated on first request from Kokoro /v1/audio/voices
// audioMu guards audioCache and audioInFlight.
// audioCache maps a cache key to the Kokoro download filename returned by
// POST /v1/audio/speech with return_download_link=true.
// audioMu guards audioInFlight only.
// Completed audio filenames are persisted to the Store (PocketBase).
// audioInFlight deduplicates concurrent generation requests for the same key.
audioMu sync.Mutex
audioCache map[string]string // cacheKey → kokoro download filename
audioInFlight map[string]chan struct{} // cacheKey → closed when done
}
// New creates a new Server.
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, kokoroURL, kokoroVoice string) *Server {
func New(addr string, oCfg orchestrator.Config, novel scraper.NovelScraper, log *slog.Logger, store storage.Store, kokoroURL, kokoroVoice string) *Server {
return &Server{
addr: addr,
oCfg: oCfg,
novel: novel,
log: log,
writer: writer.New(oCfg.StaticRoot),
store: store,
kokoroURL: kokoroURL,
kokoroVoice: kokoroVoice,
audioCache: make(map[string]string),
audioInFlight: make(map[string]chan struct{}),
}
}
@@ -174,7 +171,7 @@ func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
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
@@ -223,15 +220,14 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
// Fast path: already generated this session.
s.audioMu.Lock()
if filename, ok := s.audioCache[cacheKey]; ok {
s.audioMu.Unlock()
// Fast path: already generated (check persistent store first).
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
s.writeAudioResponse(w, slug, n, voice, speed, filename)
return
}
// Deduplicate concurrent generation for the same key.
s.audioMu.Lock()
if ch, ok := s.audioInFlight[cacheKey]; ok {
s.audioMu.Unlock()
select {
@@ -240,10 +236,8 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"request cancelled"}`, http.StatusServiceUnavailable)
return
}
s.audioMu.Lock()
filename, ok := s.audioCache[cacheKey]
s.audioMu.Unlock()
if ok {
// Check store again after waiting.
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
s.writeAudioResponse(w, slug, n, voice, speed, filename)
} else {
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
@@ -262,7 +256,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
}()
// Load and validate chapter text.
raw, err := s.writer.ReadChapter(slug, n)
raw, err := s.store.ReadChapter(r.Context(), slug, n)
if err != nil {
http.Error(w, `{"error":"chapter not found"}`, http.StatusNotFound)
return
@@ -287,9 +281,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
return
}
s.audioMu.Lock()
s.audioCache[cacheKey] = filename
s.audioMu.Unlock()
_ = s.store.SetAudioCache(r.Context(), cacheKey, filename)
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
s.writeAudioResponse(w, slug, n, voice, speed, filename)
@@ -378,10 +370,7 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
}
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
s.audioMu.Lock()
filename, ok := s.audioCache[cacheKey]
s.audioMu.Unlock()
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
if !ok {
http.Error(w, "audio not generated yet", http.StatusNotFound)
return
@@ -462,7 +451,7 @@ func (s *Server) runAsync(w http.ResponseWriter, cfg orchestrator.Config) {
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("scrape job failed", "err", fmt.Sprintf("%v", err))
}