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 }