Files
pipu-music/services/api/internal/search/service.go
T
mrleo1nid 82f3eed9a0
ci / api / api (push) Successful in 56s
ci / db / db (push) Successful in 1m13s
Improve search relevance and artist transliteration
2026-10-30 12:09:24 +03:00

254 lines
7.4 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 {
dto library.DTO
meili *Meili
catalog *library.Store
indexer *Indexer
aff *Affinity
log *slog.Logger
}
// NewHandler создаёт обработчик поиска.
func NewHandler(
meili *Meili, catalog *library.Store, indexer *Indexer, aff *Affinity, dto library.DTO, log *slog.Logger,
) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, aff: aff, dto: dto, log: log}
}
type limits struct{ artists, albums, tracks, offset int }
type found struct {
artists, albums, tracks []string
exactArtists []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 {
attrs := []string{"id"}
if index == IndexArtists {
attrs = append(attrs, "name", "name_translit")
}
queries = append(queries, MultiQuery{IndexUID: index, Q: q, Limit: n, Offset: l.offset, MatchingStrategy: "all", AttributesToRetrieve: attrs})
}
}
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
f.exactArtists = res[i].ExactIDs
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, h.dto.AlbumSummary(a))
}
}
if len(f.tracks) > 0 {
rows, err := h.catalog.TracksByIDs(ctx, ids(f.tracks))
if err != nil {
return out, err
}
if out.Tracks, err = h.dto.Tracks(ctx, rows); err != nil {
return out, err
}
}
return out, nil
}
// SearchSuggest реализует gen.StrictServerInterface.
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
want := limits{artists: 3, albums: 3, tracks: 5}
l := want
if h.aff != nil {
l = limits{artists: want.artists * suggestPool, albums: want.albums * suggestPool, tracks: want.tracks * suggestPool}
}
f, corrected, err := h.run(ctx, req.Params.Q, l)
if err != nil {
return nil, err
}
f = h.personal(ctx, f, want)
out, err := h.hydrate(ctx, f, corrected)
if err != nil {
return nil, err
}
return gen.SearchSuggest200JSONResponse(out), nil
}
// personal оставляет в подсказках по want.* лучших с учётом вкуса пользователя.
// Без пользователя или при сбое — просто первые по релевантности.
func (h *Handler) personal(ctx context.Context, f found, want limits) found {
var aff map[uuid.UUID]float64
if p, ok := auth.PrincipalFrom(ctx); ok && h.aff != nil {
all := ids(append(append(append([]string{}, f.artists...), f.albums...), f.tracks...))
var err error
if aff, err = h.aff.Of(ctx, p.UserID, all); err != nil {
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
}
}
f.artists = personalizeArtists(f.artists, f.exactArtists, aff, want.artists)
f.albums = personalize(f.albums, aff, want.albums)
f.tracks = personalize(f.tracks, aff, want.tracks)
return f
}
// 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
}