Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 10s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / UI / Build (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 10s
When a book is not in the local DB and the preview endpoint is hit, metadata and the chapter index are now saved to PocketBase in the background. Subsequent visits load from the local store instead of scraping live. Chapter text is not fetched until an explicit scrape job is triggered.
178 lines
9.6 KiB
Go
178 lines
9.6 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"`
|
|
}
|
|
|
|
// 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
|
|
// 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)
|
|
}
|