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
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.
208 lines
11 KiB
Go
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)
|
|
}
|