Files
libnovel/scraper/internal/storage/store.go
Admin 89f0dfb113
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Add async audio generation: job tracking in PocketBase + UI polling
Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
2026-03-07 20:12:08 +05:00

208 lines
11 KiB
Go

// Package storage defines the unified Store interface and helper types used by
// the server and orchestrator. Concrete implementations back the interface
// with PocketBase (structured data) and MinIO (binary objects).
package storage
import (
"context"
"time"
"github.com/libnovel/scraper/internal/scraper"
)
// ─── Shared types ─────────────────────────────────────────────────────────────
// ChapterInfo is a lightweight chapter descriptor (mirrors writer.ChapterInfo).
type ChapterInfo struct {
Number int
Title string
Date string
}
// RankingItem represents a single entry in the novel ranking list.
// Aliased from scraper.RankingItem for convenience within this package.
type RankingItem = scraper.RankingItem
// 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"`
}
// AudioJob represents a single audio-generation job record from the
// audio_jobs collection.
type AudioJob 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"`
Status string `json:"status"` // "pending" | "generating" | "done" | "failed"
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ScrapeTask represents a single scraping job record from the scraping_tasks
// collection.
type ScrapeTask struct {
ID string `json:"id"`
Kind string `json:"kind"` // "catalogue" | "book"
TargetURL string `json:"target_url"` // non-empty for single-book scrapes
Status string `json:"status"` // "running" | "done" | "failed" | "cancelled"
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"`
}
// ScrapeTaskUpdate carries the fields that can be patched on a ScrapeTask.
// Zero-value fields are still sent; callers should only include keys they want
// to change via the map form used inside the store implementation.
type ScrapeTaskUpdate struct {
Status string
BooksFound int
ChaptersScraped int
ChaptersSkipped int
Errors int
Finished time.Time // zero = not finished yet
ErrorMessage string
}
// ─── Store interface ──────────────────────────────────────────────────────────
// Store is the single persistence abstraction consumed by the server and the
// orchestrator. Implementations may route calls to different backends
// (PocketBase for structured records, MinIO for binary blobs).
type Store interface {
// ── Book metadata ──────────────────────────────────────────────────────
// WriteMetadata upserts book metadata.
WriteMetadata(ctx context.Context, meta scraper.BookMeta) error
// ReadMetadata returns the metadata for slug. Returns (zero, false, nil)
// when the book is not found.
ReadMetadata(ctx context.Context, slug string) (scraper.BookMeta, bool, error)
// ListBooks returns all books, sorted alphabetically by title.
ListBooks(ctx context.Context) ([]scraper.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
// ── Chapters (binary blobs in MinIO) ───────────────────────────────────
// ChapterExists returns true if the markdown file for the given ref exists.
ChapterExists(ctx context.Context, slug string, ref scraper.ChapterRef) bool
// WriteChapter stores the chapter markdown.
WriteChapter(ctx context.Context, slug string, chapter scraper.Chapter) error
// WriteChapterRefs persists chapter metadata (number + title) into the
// chapters_idx table without fetching or storing any chapter text.
// It is used to pre-populate the chapter list when a book is first seen
// via a live preview, before its chapter text has been scraped.
WriteChapterRefs(ctx context.Context, slug string, refs []scraper.ChapterRef) error
// 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) ([]ChapterInfo, error)
// CountChapters returns the number of stored chapters for slug.
CountChapters(ctx context.Context, slug string) int
// ReindexChapters rebuilds chapters_idx from MinIO objects for slug.
// Returns the number of chapters indexed.
ReindexChapters(ctx context.Context, slug string) (int, error)
// ── Ranking ────────────────────────────────────────────────────────────
// WriteRankingItem upserts a single ranking entry (keyed on Slug).
WriteRankingItem(ctx context.Context, item RankingItem) error
// ReadRankingItems returns all ranking items sorted by rank ascending.
ReadRankingItems(ctx context.Context) ([]RankingItem, error)
// RankingFreshEnough returns true when ranking rows exist and the most
// recent Updated timestamp is within maxAge of now.
RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error)
// ── Audio cache ────────────────────────────────────────────────────────
// GetAudioCache returns the Kokoro filename for cacheKey, or ("", false).
GetAudioCache(ctx context.Context, cacheKey string) (string, bool)
// SetAudioCache persists a Kokoro filename for cacheKey.
SetAudioCache(ctx context.Context, cacheKey, filename string) error
// PutAudio stores raw audio bytes under the given MinIO object key.
PutAudio(ctx context.Context, key string, data []byte) error
// ── Reading progress ───────────────────────────────────────────────────
// GetProgress returns the reading progress for the given session ID and slug.
// Returns (zero, false) if no progress is recorded.
GetProgress(ctx context.Context, sessionID, slug string) (ReadingProgress, bool)
// SetProgress saves or updates reading progress.
SetProgress(ctx context.Context, sessionID string, p ReadingProgress) error
// AllProgress returns all progress entries for a session.
AllProgress(ctx context.Context, sessionID string) ([]ReadingProgress, error)
// DeleteProgress removes progress for a specific slug.
DeleteProgress(ctx context.Context, sessionID, slug string) error
// ── Audio object paths (MinIO) ─────────────────────────────────────────
// 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 the bucket.
AudioExists(ctx context.Context, key string) bool
// ── Presigned URLs ─────────────────────────────────────────────────────
// 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)
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
SaveBrowsePage(ctx context.Context, key, html string) error
// GetBrowsePage retrieves a cached HTML snapshot. Returns ("", false, nil)
// when no snapshot exists for the key.
GetBrowsePage(ctx context.Context, key string) (string, bool, error)
// BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot.
// Layout: {domain}/html/page-{n}.html
BrowseHTMLKey(domain string, page int) string
// BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot
// that incorporates sort/genre/status so different filter combos are cached separately.
BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string
// BrowseCoverKey returns the MinIO object key for a cached book cover image.
// Layout: {domain}/assets/book-covers/{slug}.jpg
BrowseCoverKey(domain, slug string) string
// SaveBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket.
SaveBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error
// GetBrowseAsset retrieves a binary asset from the browse bucket.
// Returns (nil, "", false, nil) when the object does not exist.
GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error)
// ── Scraping tasks ─────────────────────────────────────────────────────
// CreateScrapeTask inserts a new scraping_tasks record with status="running"
// and returns the assigned ID.
CreateScrapeTask(ctx context.Context, kind, targetURL string) (string, error)
// UpdateScrapeTask patches an existing task record.
UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error
// ListScrapeTasks returns all tasks sorted by started descending.
ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error)
// ── Audio jobs ─────────────────────────────────────────────────────────
// CreateAudioJob inserts a new audio_jobs record with status="pending"
// and returns the assigned ID.
CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error)
// UpdateAudioJob patches an existing audio job record (status, error, finished).
UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error
// GetAudioJob returns the most recent audio job for the given cache key,
// or (zero, false, nil) if none exists.
GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error)
// ListAudioJobs returns all audio jobs sorted by started descending.
ListAudioJobs(ctx context.Context) ([]AudioJob, error)
}