Suggest fetches three times as many candidates and reranks them in the API: what the user plays, finishes and likes moves up a few positions and what they dislike moves down, with Meilisearch relevance still the base order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
7.1 KiB
Go
248 lines
7.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 {
|
|
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
|
|
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, 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 = personalize(f.artists, 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
|
|
}
|