// Package meili provides a thin Meilisearch client for indexing and searching // locally scraped books. // // Index: // - Name: "books" // - Primary key: "slug" // - Searchable attributes: title, author, genres, summary // - Filterable attributes: status, genres // - Sortable attributes: rank, rating, total_chapters, meta_updated // // The client is intentionally simple: UpsertBook and Search only. All // Meilisearch-specific details (index management, attribute configuration) // are handled once in Configure(), called at startup. package meili import ( "context" "encoding/json" "fmt" "strings" "github.com/libnovel/backend/internal/domain" "github.com/meilisearch/meilisearch-go" ) const indexName = "books" // Client is the interface for Meilisearch operations used by runner and backend. type Client interface { // UpsertBook adds or updates a book document in the search index. UpsertBook(ctx context.Context, book domain.BookMeta) error // BookExists reports whether a book with the given slug is already in the // index. Used by the catalogue refresh to skip re-indexing known books. BookExists(ctx context.Context, slug string) bool // DeleteBook removes a book document from the search index by slug. DeleteBook(ctx context.Context, slug string) error // Search returns up to limit books matching query. // Archived books are always excluded. Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error) // Catalogue queries books with optional filters, sort, and pagination. // Returns books, the total hit count for pagination, and a FacetResult // with available genre and status values from the index. // Archived books are always excluded. Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error) } // CatalogueQuery holds parameters for the /api/catalogue endpoint. type CatalogueQuery struct { Q string // full-text query (may be empty for browse) Genre string // genre filter, e.g. "fantasy" or "all" Status string // status filter, e.g. "ongoing", "completed", or "all" Sort string // sort field: "popular", "new", "update", "top-rated", "rank", "" Page int // 1-indexed Limit int // items per page, default 20 } // FacetResult holds the available filter values discovered from the index. // Values are sorted alphabetically and include only those present in the index. type FacetResult struct { Genres []string // distinct genre values Statuses []string // distinct status values } // MeiliClient wraps the meilisearch-go SDK. type MeiliClient struct { idx meilisearch.IndexManager } // New creates a MeiliClient. Call Configure() once at startup to ensure the // index exists and has the correct attribute settings. func New(host, apiKey string) *MeiliClient { cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey)) return &MeiliClient{idx: cli.Index(indexName)} } // Configure creates the index if absent and sets searchable/filterable // attributes. It is idempotent — safe to call on every startup. func Configure(host, apiKey string) error { cli := meilisearch.New(host, meilisearch.WithAPIKey(apiKey)) // Create index with primary key. Returns 202 if exists — ignore. task, err := cli.CreateIndex(&meilisearch.IndexConfig{ Uid: indexName, PrimaryKey: "slug", }) if err != nil { // 400 "index_already_exists" is not an error here; the SDK returns // an error with Code "index_already_exists" which we can ignore. // Any other error is fatal. if apiErr, ok := err.(*meilisearch.Error); ok && apiErr.MeilisearchApiError.Code == "index_already_exists" { // already exists — continue } else { return fmt.Errorf("meili: create index: %w", err) } } else { _ = task // task is async; we don't wait for it } idx := cli.Index(indexName) searchable := []string{"title", "author", "genres", "summary"} if _, err := idx.UpdateSearchableAttributes(&searchable); err != nil { return fmt.Errorf("meili: update searchable attributes: %w", err) } filterable := []interface{}{"status", "genres", "archived"} if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil { return fmt.Errorf("meili: update filterable attributes: %w", err) } sortable := []string{"rank", "rating", "total_chapters", "meta_updated"} if _, err := idx.UpdateSortableAttributes(&sortable); err != nil { return fmt.Errorf("meili: update sortable attributes: %w", err) } return nil } // bookDoc is the Meilisearch document shape for a book. type bookDoc struct { Slug string `json:"slug"` Title string `json:"title"` Author string `json:"author"` Cover string `json:"cover"` Status string `json:"status"` Genres []string `json:"genres"` Summary string `json:"summary"` TotalChapters int `json:"total_chapters"` SourceURL string `json:"source_url"` Rank int `json:"rank"` Rating float64 `json:"rating"` // MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update. // Used for sort=update ("recently updated" ordering). MetaUpdated int64 `json:"meta_updated"` // Archived is true when the book has been soft-deleted by an admin. // Used as a filter to exclude archived books from all search results. Archived bool `json:"archived"` } func toDoc(b domain.BookMeta) bookDoc { return bookDoc{ Slug: b.Slug, Title: b.Title, Author: b.Author, Cover: b.Cover, Status: b.Status, Genres: b.Genres, Summary: b.Summary, TotalChapters: b.TotalChapters, SourceURL: b.SourceURL, Rank: b.Ranking, Rating: b.Rating, MetaUpdated: b.MetaUpdated, Archived: b.Archived, } } func fromDoc(d bookDoc) domain.BookMeta { return domain.BookMeta{ Slug: d.Slug, Title: d.Title, Author: d.Author, Cover: d.Cover, Status: d.Status, Genres: d.Genres, Summary: d.Summary, TotalChapters: d.TotalChapters, SourceURL: d.SourceURL, Ranking: d.Rank, Rating: d.Rating, MetaUpdated: d.MetaUpdated, Archived: d.Archived, } } // UpsertBook adds or replaces the book document in Meilisearch. The operation // is fire-and-forget (Meilisearch processes tasks asynchronously). func (c *MeiliClient) UpsertBook(_ context.Context, book domain.BookMeta) error { docs := []bookDoc{toDoc(book)} pk := "slug" if _, err := c.idx.AddDocuments(docs, &meilisearch.DocumentOptions{PrimaryKey: &pk}); err != nil { return fmt.Errorf("meili: upsert book %q: %w", book.Slug, err) } return nil } // BookExists reports whether the slug is already present in the index. // It fetches the document by primary key; a 404 or any error is treated as // "not present" (safe default: re-index rather than silently skip). func (c *MeiliClient) BookExists(_ context.Context, slug string) bool { var doc bookDoc err := c.idx.GetDocument(slug, nil, &doc) return err == nil && doc.Slug != "" } // DeleteBook removes a book document from the index by slug. // The operation is fire-and-forget (Meilisearch processes tasks asynchronously). func (c *MeiliClient) DeleteBook(_ context.Context, slug string) error { if _, err := c.idx.DeleteDocument(slug, nil); err != nil { return fmt.Errorf("meili: delete book %q: %w", slug, err) } return nil } // Search returns books matching query, up to limit results. // Archived books are always excluded. func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) { if limit <= 0 { limit = 20 } res, err := c.idx.Search(query, &meilisearch.SearchRequest{ Limit: int64(limit), Filter: "archived = false", }) if err != nil { return nil, fmt.Errorf("meili: search %q: %w", query, err) } books := make([]domain.BookMeta, 0, len(res.Hits)) for _, hit := range res.Hits { // Hit is map[string]json.RawMessage — unmarshal directly into bookDoc. var doc bookDoc raw, err := json.Marshal(hit) if err != nil { continue } if err := json.Unmarshal(raw, &doc); err != nil { continue } books = append(books, fromDoc(doc)) } return books, nil } // Catalogue queries books with optional full-text search, genre/status filters, // sort order, and pagination. Returns matching books, the total estimate, and // a FacetResult containing available genre and status values from the index. func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error) { if q.Limit <= 0 { q.Limit = 20 } if q.Page <= 0 { q.Page = 1 } req := &meilisearch.SearchRequest{ Limit: int64(q.Limit), Offset: int64((q.Page - 1) * q.Limit), // Request facet distribution so the UI can build filter options // dynamically without hardcoding genre/status lists. Facets: []string{"genres", "status"}, } // Build filter — always exclude archived books filters := []string{"archived = false"} if q.Genre != "" && q.Genre != "all" { filters = append(filters, fmt.Sprintf("genres = %q", q.Genre)) } if q.Status != "" && q.Status != "all" { filters = append(filters, fmt.Sprintf("status = %q", q.Status)) } req.Filter = strings.Join(filters, " AND ") // Map UI sort tokens to Meilisearch sort expressions. switch q.Sort { case "rank": req.Sort = []string{"rank:asc"} case "top-rated": req.Sort = []string{"rating:desc"} case "new": req.Sort = []string{"total_chapters:desc"} case "update": req.Sort = []string{"meta_updated:desc"} // "popular" and "" → relevance (no explicit sort) } res, err := c.idx.Search(q.Q, req) if err != nil { return nil, 0, FacetResult{}, fmt.Errorf("meili: catalogue query: %w", err) } books := make([]domain.BookMeta, 0, len(res.Hits)) for _, hit := range res.Hits { var doc bookDoc raw, err := json.Marshal(hit) if err != nil { continue } if err := json.Unmarshal(raw, &doc); err != nil { continue } books = append(books, fromDoc(doc)) } facets := parseFacets(res.FacetDistribution) return books, res.EstimatedTotalHits, facets, nil } // parseFacets extracts sorted genre and status slices from a Meilisearch // facetDistribution raw JSON value. // The JSON shape is: {"genres":{"fantasy":12,"action":5},"status":{"ongoing":7}} func parseFacets(raw json.RawMessage) FacetResult { var result FacetResult if len(raw) == 0 { return result } var dist map[string]map[string]int64 if err := json.Unmarshal(raw, &dist); err != nil { return result } if m, ok := dist["genres"]; ok { for k := range m { result.Genres = append(result.Genres, k) } sortStrings(result.Genres) } if m, ok := dist["status"]; ok { for k := range m { result.Statuses = append(result.Statuses, k) } sortStrings(result.Statuses) } return result } // sortStrings sorts a slice of strings in place. func sortStrings(s []string) { for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j] < s[j-1]; j-- { s[j], s[j-1] = s[j-1], s[j] } } } // NoopClient is a no-op Client used when Meilisearch is not configured. type NoopClient struct{} func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil } func (NoopClient) BookExists(_ context.Context, _ string) bool { return false } func (NoopClient) DeleteBook(_ context.Context, _ string) error { return nil } func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) { return nil, nil } func (NoopClient) Catalogue(_ context.Context, _ CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error) { return nil, 0, FacetResult{}, nil }