Files
pipu-music/services/api/internal/search/meili.go
T
mrleo1nidandClaude Opus 5.5 c1160cccd3
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
feat(api): search with Meilisearch (M2)
- 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>
2026-10-20 06:51:50 +03:00

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
}