// Package bookstore defines the segregated read/write interfaces for book, // chapter, ranking, progress, audio, and presign data. // // Interface segregation: // - BookWriter — used by the runner to persist scraped data. // - BookReader — used by the backend to serve book/chapter data. // - RankingStore — used by both runner (write) and backend (read). // - PresignStore — used only by the backend for URL signing. // - AudioStore — used by the runner to store audio; backend for presign. // - ProgressStore— used only by the backend for reading progress. // // Concrete implementations live in internal/storage. package bookstore import ( "context" "io" "time" "github.com/libnovel/backend/internal/domain" ) // BookWriter is the write side used by the runner after scraping a book. type BookWriter interface { // WriteMetadata upserts all bibliographic fields for a book. WriteMetadata(ctx context.Context, meta domain.BookMeta) error // WriteChapter stores a fully-scraped chapter's text in MinIO and // updates the chapters_idx record in PocketBase. WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error // WriteChapterRefs persists chapter metadata (number + title) into // chapters_idx without fetching or storing chapter text. WriteChapterRefs(ctx context.Context, slug string, refs []domain.ChapterRef) error // ChapterExists returns true if the markdown object for ref already exists. ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool // DeduplicateChapters removes duplicate chapters_idx records for slug, // keeping only one record per chapter number (the one with the latest // updated timestamp). Returns the number of duplicate records deleted. DeduplicateChapters(ctx context.Context, slug string) (int, error) } // BookReader is the read side used by the backend to serve content. type BookReader interface { // ReadMetadata returns the metadata for slug. // Returns (zero, false, nil) when not found. ReadMetadata(ctx context.Context, slug string) (domain.BookMeta, bool, error) // ListBooks returns all books sorted alphabetically by title. ListBooks(ctx context.Context) ([]domain.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 // 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) ([]domain.ChapterInfo, error) // CountChapters returns the count of stored chapters. CountChapters(ctx context.Context, slug string) int // ReindexChapters rebuilds chapters_idx from MinIO objects for slug. ReindexChapters(ctx context.Context, slug string) (int, error) } // RankingStore covers ranking reads and writes. type RankingStore interface { // WriteRankingItem upserts a single ranking entry (keyed on Slug). WriteRankingItem(ctx context.Context, item domain.RankingItem) error // ReadRankingItems returns all ranking items sorted by rank ascending. ReadRankingItems(ctx context.Context) ([]domain.RankingItem, error) // RankingFreshEnough returns true when ranking rows exist and the most // recent Updated timestamp is within maxAge. RankingFreshEnough(ctx context.Context, maxAge time.Duration) (bool, error) } // AudioStore covers audio object storage (runner writes; backend reads). type AudioStore interface { // AudioObjectKey returns the MinIO object key for a cached MP3 audio file. // Format: {slug}/{n}/{voice}.mp3 AudioObjectKey(slug string, n int, voice string) string // AudioObjectKeyExt returns the MinIO object key for a cached audio file // with a custom extension (e.g. "mp3" or "wav"). AudioObjectKeyExt(slug string, n int, voice, ext string) string // AudioExists returns true when the audio object is present in MinIO. AudioExists(ctx context.Context, key string) bool // PutAudio stores raw audio bytes under the given MinIO object key. PutAudio(ctx context.Context, key string, data []byte) error // PutAudioStream uploads audio from r to MinIO under key. // size must be the exact byte length of r, or -1 to use multipart upload. // contentType should be "audio/mpeg" or "audio/wav". PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error } // PresignStore generates short-lived URLs — used exclusively by the backend. type PresignStore interface { // 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. 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. // Returns ("", false, nil) when no avatar exists. PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) // PutAvatar stores raw image bytes for a user avatar directly in MinIO. // ext should be "jpg", "png", or "webp". Returns the object key. PutAvatar(ctx context.Context, userID, ext, contentType string, data []byte) (key string, err error) // DeleteAvatar removes all avatar objects for a user. DeleteAvatar(ctx context.Context, userID string) error } // ProgressStore covers per-session reading progress — backend only. type ProgressStore interface { // GetProgress returns the reading progress for the given session + slug. GetProgress(ctx context.Context, sessionID, slug string) (domain.ReadingProgress, bool) // SetProgress saves or updates reading progress. SetProgress(ctx context.Context, sessionID string, p domain.ReadingProgress) error // AllProgress returns all progress entries for a session. AllProgress(ctx context.Context, sessionID string) ([]domain.ReadingProgress, error) // DeleteProgress removes progress for a specific slug. DeleteProgress(ctx context.Context, sessionID, slug string) error } // CoverStore covers book cover image storage in MinIO. // The runner writes covers during catalogue refresh; the backend reads them. type CoverStore interface { // PutCover stores a raw cover image for a book identified by slug. PutCover(ctx context.Context, slug string, data []byte, contentType string) error // GetCover retrieves the cover image for a book. Returns (nil, false, nil) // when no cover exists for the given slug. GetCover(ctx context.Context, slug string) ([]byte, string, bool, error) // CoverExists returns true when a cover image is stored for slug. CoverExists(ctx context.Context, slug string) bool } // AIJobStore manages AI generation jobs tracked in PocketBase. type AIJobStore interface { // CreateAIJob inserts a new ai_job record with status=running and returns its ID. CreateAIJob(ctx context.Context, job domain.AIJob) (string, error) // GetAIJob retrieves a single ai_job by ID. // Returns (zero, false, nil) when not found. GetAIJob(ctx context.Context, id string) (domain.AIJob, bool, error) // UpdateAIJob patches an existing ai_job record with the given fields. UpdateAIJob(ctx context.Context, id string, fields map[string]any) error // ListAIJobs returns all ai_job records sorted by started descending. ListAIJobs(ctx context.Context) ([]domain.AIJob, error) } // ChapterImageStore covers per-chapter illustration images stored in MinIO. // The backend admin writes them; the backend serves them. type ChapterImageStore interface { // PutChapterImage stores a raw image for chapter n of slug in MinIO. PutChapterImage(ctx context.Context, slug string, n int, data []byte, contentType string) error // GetChapterImage retrieves the image for chapter n of slug. // Returns (nil, "", false, nil) when no image exists. GetChapterImage(ctx context.Context, slug string, n int) ([]byte, string, bool, error) // ChapterImageExists returns true when an image is stored for slug/n. ChapterImageExists(ctx context.Context, slug string, n int) bool } // TranslationStore covers machine-translated chapter storage in MinIO. // The runner writes translations; the backend reads them. type TranslationStore interface { // TranslationObjectKey returns the MinIO object key for a cached translation. TranslationObjectKey(lang, slug string, n int) string // TranslationExists returns true when the translation object is present in MinIO. TranslationExists(ctx context.Context, key string) bool // PutTranslation stores raw translated markdown under the given MinIO object key. PutTranslation(ctx context.Context, key string, data []byte) error // GetTranslation retrieves translated markdown from MinIO. GetTranslation(ctx context.Context, key string) (string, error) } // Chapter represents a single chapter extracted from PDF/EPUB. type Chapter struct { Number int // 1-based chapter number Title string // chapter title (may be empty) Content string // plain text content } // BookImporter handles PDF/EPUB file parsing and chapter extraction. // Used by the runner to import books from uploaded files. type BookImporter interface { // Import extracts chapters from a PDF or EPUB file stored in MinIO. // Returns the extracted chapters or an error. Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error) } // BookAdminStore covers admin-only operations for managing books in the catalogue. // All methods require admin authorisation at the HTTP handler level. type BookAdminStore interface { // ArchiveBook sets archived=true on a book record, hiding it from all // public search and catalogue responses. Returns ErrNotFound when the // slug does not exist. ArchiveBook(ctx context.Context, slug string) error // UnarchiveBook clears archived on a book record, making it publicly // visible again. Returns ErrNotFound when the slug does not exist. UnarchiveBook(ctx context.Context, slug string) error // DeleteBook permanently removes all data for a book: // - PocketBase books record // - All PocketBase chapters_idx records // - All MinIO chapter markdown objects ({slug}/chapter-*.md) // - MinIO cover image (covers/{slug}.jpg) // The caller is responsible for also deleting the Meilisearch document. DeleteBook(ctx context.Context, slug string) error } // ImportFileStore uploads raw import files to object storage. // Kept separate from BookImporter so the HTTP handler can upload the file // without a concrete type assertion, regardless of which Producer is wired. type ImportFileStore interface { PutImportFile(ctx context.Context, objectKey string, data []byte) error // PutImportChapters stores the pre-parsed chapters JSON under the given key. PutImportChapters(ctx context.Context, key string, data []byte) error // GetImportChapters retrieves the pre-parsed chapters JSON. GetImportChapters(ctx context.Context, key string) ([]byte, error) } // NotificationStore manages per-user in-app notifications. // Always wired directly to the concrete *storage.Store so it works // regardless of whether the Asynq task-queue wrapper is in use. type NotificationStore interface { ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) MarkNotificationRead(ctx context.Context, id string) error MarkAllNotificationsRead(ctx context.Context, userID string) error DeleteNotification(ctx context.Context, id string) error ClearAllNotifications(ctx context.Context, userID string) error }