// 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) // PresignAvatarUpload returns a short-lived presigned PUT URL for uploading // an avatar image directly to MinIO, and the object key that will be stored. // ext should be "jpg", "png", or "webp". PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error) // PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none. PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) // DeleteAvatar removes all avatar objects for a user (all extensions). DeleteAvatar(ctx context.Context, userID 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) }