Files
pipu-music/services/api/internal/search/service.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

221 lines
6.1 KiB
Go

package search
import (
"context"
"encoding/base64"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок поиска.
const (
CodeUnavailable = "search.unavailable"
CodeQueryEmpty = "validation.query_empty"
)
// Handler реализует search-часть gen.StrictServerInterface.
type Handler struct {
meili *Meili
catalog *library.Store
indexer *Indexer
log *slog.Logger
}
// NewHandler создаёт обработчик поиска.
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, log *slog.Logger) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, log: log}
}
type limits struct{ artists, albums, tracks, offset int }
type found struct {
artists, albums, tracks []string
totals [3]int
}
func (h *Handler) query(ctx context.Context, q string, l limits) (found, error) {
var queries []MultiQuery
add := func(index string, n int) {
if n > 0 {
queries = append(queries, MultiQuery{IndexUID: index, Q: q, Limit: n, Offset: l.offset, AttributesToRetrieve: []string{"id"}})
}
}
add(IndexArtists, l.artists)
add(IndexAlbums, l.albums)
add(IndexTracks, l.tracks)
res, err := h.meili.MultiSearch(ctx, queries)
if err != nil {
h.log.WarnContext(ctx, "search failed", "err", err)
return found{}, apperr.New(http.StatusServiceUnavailable, CodeUnavailable, "search is unavailable")
}
var f found
i := 0
for k, n := range []int{l.artists, l.albums, l.tracks} {
if n == 0 {
continue
}
f.totals[k] = res[i].Total
switch k {
case 0:
f.artists = res[i].IDs
case 1:
f.albums = res[i].IDs
case 2:
f.tracks = res[i].IDs
}
i++
}
return f, nil
}
func (f found) empty() bool { return len(f.artists)+len(f.albums)+len(f.tracks) == 0 }
// run ищет q, а если ничего не нашлось — тот же запрос в другой раскладке.
func (h *Handler) run(ctx context.Context, q string, l limits) (found, string, error) {
q = strings.TrimSpace(q)
if q == "" {
return found{}, "", apperr.Validation("q", CodeQueryEmpty, "query is empty", nil)
}
f, err := h.query(ctx, q, l)
if err != nil || !f.empty() || l.offset > 0 {
return f, "", err
}
alt := SwitchLayout(q)
if alt == "" {
return f, "", nil
}
f2, err := h.query(ctx, alt, l)
if err != nil || f2.empty() {
return f, "", err
}
return f2, alt, nil
}
func ids(raw []string) []uuid.UUID {
out := make([]uuid.UUID, 0, len(raw))
for _, s := range raw {
if id, err := uuid.Parse(s); err == nil {
out = append(out, id)
}
}
return out
}
// hydrate превращает найденные id в DTO; индекс может отставать от БД — удалённое пропускается.
func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.SearchResults, error) {
out := gen.SearchResults{Artists: []gen.ArtistRef{}, Albums: []gen.AlbumSummary{}, Tracks: []gen.Track{}}
out.Totals.Artists, out.Totals.Albums, out.Totals.Tracks = f.totals[0], f.totals[1], f.totals[2]
if corrected != "" {
out.CorrectedQuery = &corrected
}
if len(f.artists) > 0 {
refs, err := h.catalog.ArtistsByIDs(ctx, ids(f.artists))
if err != nil {
return out, err
}
for _, a := range refs {
out.Artists = append(out.Artists, gen.ArtistRef{Id: a.ID, Name: a.Name})
}
}
if len(f.albums) > 0 {
rows, err := h.catalog.AlbumsByIDs(ctx, ids(f.albums))
if err != nil {
return out, err
}
for _, a := range rows {
out.Albums = append(out.Albums, library.AlbumSummaryDTO(a))
}
}
if len(f.tracks) > 0 {
rows, err := h.catalog.TracksByIDs(ctx, ids(f.tracks))
if err != nil {
return out, err
}
for _, t := range rows {
out.Tracks = append(out.Tracks, library.TrackDTO(t))
}
}
return out, nil
}
// SearchSuggest реализует gen.StrictServerInterface.
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
f, corrected, err := h.run(ctx, req.Params.Q, limits{artists: 3, albums: 3, tracks: 5})
if err != nil {
return nil, err
}
out, err := h.hydrate(ctx, f, corrected)
if err != nil {
return nil, err
}
return gen.SearchSuggest200JSONResponse(out), nil
}
// Search реализует gen.StrictServerInterface.
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
typ := gen.All
if req.Params.Type != nil {
typ = *req.Params.Type
}
limit := 30
if req.Params.Limit != nil {
limit = min(max(*req.Params.Limit, 1), 100)
}
offset := 0
if req.Params.Cursor != nil {
raw, err := base64.RawURLEncoding.DecodeString(*req.Params.Cursor)
if err == nil {
offset, err = strconv.Atoi(string(raw))
}
if err != nil || offset < 0 {
return nil, apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil)
}
}
var l limits
switch typ {
case gen.Artists:
l = limits{artists: limit, offset: offset}
case gen.Albums:
l = limits{albums: limit, offset: offset}
case gen.Tracks:
l = limits{tracks: limit, offset: offset}
default:
l = limits{artists: 6, albums: 6, tracks: 20}
}
f, corrected, err := h.run(ctx, req.Params.Q, l)
if err != nil {
return nil, err
}
out, err := h.hydrate(ctx, f, corrected)
if err != nil {
return nil, err
}
if typ != gen.All {
total := f.totals[0] + f.totals[1] + f.totals[2]
if offset+limit < total {
next := base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + limit)))
out.NextCursor = &next
}
}
return gen.Search200JSONResponse(out), nil
}
// ReindexSearch реализует gen.StrictServerInterface.
func (h *Handler) ReindexSearch(ctx context.Context, _ gen.ReindexSearchRequestObject) (gen.ReindexSearchResponseObject, error) {
p, ok := auth.PrincipalFrom(ctx)
if !ok || !auth.Can(p.Role, auth.PermManageLibrary) {
return nil, apperr.Forbidden(auth.CodeForbidden, "not allowed")
}
h.indexer.Trigger()
return gen.ReindexSearch202Response{}, nil
}