- Add `archived` bool to domain.BookMeta, pbBook, and Meilisearch bookDoc
- ArchiveBook / UnarchiveBook patch the PocketBase record; ListBooks filters
archived=false so hidden books disappear from all public responses
- Meilisearch: add `archived` as a filterable attribute; Search and Catalogue
always prepend `archived = false` to exclude archived books from results
- DeleteBook permanently removes the PocketBase record, all chapters_idx rows,
MinIO chapter objects, cover image, and the Meilisearch document
- New BookAdminStore interface with ArchiveBook, UnarchiveBook, DeleteBook
- Admin HTTP endpoints: PATCH /api/admin/books/{slug}/archive|unarchive,
DELETE /api/admin/books/{slug}
- PocketBase schema: archived field added to live pb.libnovel.cc and to
pb-init-v3.sh (both create block and add_field migration)
347 lines
11 KiB
Go
347 lines
11 KiB
Go
// 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
|
|
}
|