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>
154 lines
4.1 KiB
Go
154 lines
4.1 KiB
Go
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
|
|
}
|