feat(api): search with Meilisearch (M2)
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
- artists, albums and tracks indexes are rebuilt from PostgreSQL next to the live ones and swapped in, so search keeps working; a rebuild runs on start, after every scan and when a source is removed - transliteration fields (Кино ↔ kino) and a retry in the other keyboard layout (ghbdtn → привет) reported as corrected_query - /search/suggest (3 artists, 3 albums, 5 tracks), /search by type with cursor pages, /admin/search/reindex; Meilisearch is part of /readyz - found ids are hydrated from the catalog, so a lagging index never returns deleted entries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c583991d14
commit
c1160cccd3
16 files changed
+1590
-124
No files matched your search
@@ -0,0 +1,153 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Meili — минимальный REST-клиент Meilisearch: только то, что нужно индексатору и поиску.
|
||||
type Meili struct {
|
||||
URL string
|
||||
Key string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewMeili создаёт клиента.
|
||||
func NewMeili(url, key string) *Meili {
|
||||
return &Meili{URL: url, Key: key, Client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// ErrMeiliNotFound — индекс или задача не найдены.
|
||||
var ErrMeiliNotFound = errors.New("meilisearch: not found")
|
||||
|
||||
func (m *Meili) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(raw)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, m.URL+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+m.Key)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := m.Client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("meilisearch: %w", err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
return ErrMeiliNotFound
|
||||
}
|
||||
if res.StatusCode >= 300 {
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.NewDecoder(res.Body).Decode(&e)
|
||||
return fmt.Errorf("meilisearch: %s %s: %d %s %s", method, path, res.StatusCode, e.Code, e.Message)
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(res.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type taskRef struct {
|
||||
TaskUID int64 `json:"taskUid"`
|
||||
}
|
||||
|
||||
// wait ждёт завершения асинхронной задачи Meilisearch.
|
||||
func (m *Meili) wait(ctx context.Context, t taskRef) error {
|
||||
for {
|
||||
var task struct {
|
||||
Status string `json:"status"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil {
|
||||
return err
|
||||
}
|
||||
switch task.Status {
|
||||
case "succeeded":
|
||||
return nil
|
||||
case "failed", "canceled":
|
||||
msg := task.Status
|
||||
if task.Error != nil {
|
||||
msg = task.Error.Message
|
||||
}
|
||||
return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call выполняет запрос, возвращающий задачу, и ждёт её.
|
||||
func (m *Meili) call(ctx context.Context, method, path string, body any) error {
|
||||
var t taskRef
|
||||
if err := m.do(ctx, method, path, body, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.wait(ctx, t)
|
||||
}
|
||||
|
||||
// Healthy — Meilisearch отвечает.
|
||||
func (m *Meili) Healthy(ctx context.Context) error {
|
||||
return m.do(ctx, http.MethodGet, "/health", nil, nil)
|
||||
}
|
||||
|
||||
// MultiQuery — один запрос multi-search.
|
||||
type MultiQuery struct {
|
||||
IndexUID string `json:"indexUid"`
|
||||
Q string `json:"q"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
AttributesToRetrieve []string `json:"attributesToRetrieve"`
|
||||
}
|
||||
|
||||
// MultiResult — ответ на один запрос: id найденных документов и оценка общего числа.
|
||||
type MultiResult struct {
|
||||
IDs []string
|
||||
Total int
|
||||
}
|
||||
|
||||
// MultiSearch выполняет несколько запросов за один вызов.
|
||||
func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiResult, error) {
|
||||
var res struct {
|
||||
Results []struct {
|
||||
Hits []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"hits"`
|
||||
EstimatedTotalHits int `json:"estimatedTotalHits"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := m.do(ctx, http.MethodPost, "/multi-search", map[string]any{"queries": queries}, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]MultiResult, len(res.Results))
|
||||
for i, r := range res.Results {
|
||||
for _, h := range r.Hits {
|
||||
out[i].IDs = append(out[i].IDs, h.ID)
|
||||
}
|
||||
out[i].Total = r.EstimatedTotalHits
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user