feat(api): search with Meilisearch (M2)
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

- 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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:51:50 +03:00
1 parent c583991d14
commit c1160cccd3
16 files changed
+1590 -124

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
API (из `services/api`):
```bash
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" go run ./cmd/api
```
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
+101
View File
@@ -14,6 +14,7 @@ tags:
- name: admin
- name: library
- name: catalog
- name: search
security:
- bearerAuth: []
paths:
@@ -665,6 +666,72 @@ paths:
$ref: "#/components/schemas/Track"
default:
$ref: "#/components/responses/Problem"
/api/v1/search/suggest:
get:
operationId: searchSuggest
tags: [search]
summary: Выпадающий список поиска — до 3 исполнителей, 3 альбомов, 5 треков
description: |
Если по запросу ничего не найдено, сервер повторяет его в другой раскладке клавиатуры
(«ghbdtn» → «привет») и возвращает исправленный запрос в `corrected_query`.
parameters:
- name: q
in: query
required: true
schema:
type: string
maxLength: 200
responses:
"200":
description: Подсказки
content:
application/json:
schema:
$ref: "#/components/schemas/SearchResults"
default:
$ref: "#/components/responses/Problem"
/api/v1/search:
get:
operationId: search
tags: [search]
summary: Страница поиска
description: |
`type=all` — по несколько результатов каждого вида без пагинации;
конкретный тип — страницы с `next_cursor`.
parameters:
- name: q
in: query
required: true
schema:
type: string
maxLength: 200
- name: type
in: query
schema:
type: string
enum: [all, artists, albums, tracks]
default: all
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Результаты
content:
application/json:
schema:
$ref: "#/components/schemas/SearchResults"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/search/reindex:
post:
operationId: reindexSearch
tags: [search]
summary: Пересобрать поисковые индексы (admin)
responses:
"202":
description: Переиндексация запущена
default:
$ref: "#/components/responses/Problem"
components:
parameters:
SourceId:
@@ -1461,6 +1528,40 @@ components:
type: array
items:
$ref: "#/components/schemas/Track"
SearchResults:
type: object
required: [artists, albums, tracks, totals, corrected_query, next_cursor]
properties:
artists:
type: array
items:
$ref: "#/components/schemas/ArtistRef"
albums:
type: array
items:
$ref: "#/components/schemas/AlbumSummary"
tracks:
type: array
items:
$ref: "#/components/schemas/Track"
totals:
type: object
required: [artists, albums, tracks]
description: Оценка общего числа совпадений
properties:
artists:
type: integer
albums:
type: integer
tracks:
type: integer
corrected_query:
type: string
nullable: true
description: Запрос в другой раскладке, если по исходному ничего не нашлось
next_cursor:
type: string
nullable: true
Session:
type: object
required: [id, device_name, client_type, created_at, last_used_at, current]
+4
View File
@@ -45,6 +45,10 @@ services:
environment:
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
REDIS_URL: redis://redis:6379/0
MEILI_URL: http://meilisearch:7700
MEILI_MASTER_KEY: dev-master-key-change-me
INTERNAL_TOKEN: dev-internal-token
COOKIE_SECURE: "false"
LOG_LEVEL: debug
ports: ["127.0.0.1:8080:8080"]
depends_on:
+142
View File
@@ -581,6 +581,65 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/search/suggest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Выпадающий список поиска — до 3 исполнителей, 3 альбомов, 5 треков
* @description Если по запросу ничего не найдено, сервер повторяет его в другой раскладке клавиатуры
* («ghbdtn» → «привет») и возвращает исправленный запрос в `corrected_query`.
*/
get: operations["searchSuggest"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Страница поиска
* @description `type=all` — по несколько результатов каждого вида без пагинации;
* конкретный тип — страницы с `next_cursor`.
*/
get: operations["search"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/search/reindex": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Пересобрать поисковые индексы (admin) */
post: operations["reindexSearch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -961,6 +1020,20 @@ export interface components {
duration_ms: number;
tracks: components["schemas"]["Track"][];
};
SearchResults: {
artists: components["schemas"]["ArtistRef"][];
albums: components["schemas"]["AlbumSummary"][];
tracks: components["schemas"]["Track"][];
/** @description Оценка общего числа совпадений */
totals: {
artists: number;
albums: number;
tracks: number;
};
/** @description Запрос в другой раскладке, если по исходному ничего не нашлось */
corrected_query: string | null;
next_cursor: string | null;
};
Session: {
/** Format: uuid */
id: string;
@@ -1875,4 +1948,73 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
searchSuggest: {
parameters: {
query: {
q: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Подсказки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SearchResults"];
};
};
default: components["responses"]["Problem"];
};
};
search: {
parameters: {
query: {
q: string;
type?: "all" | "artists" | "albums" | "tracks";
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Результаты */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SearchResults"];
};
};
default: components["responses"]["Problem"];
};
};
reindexSearch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Переиндексация запущена */
202: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
}
+8
View File
@@ -20,6 +20,7 @@ import (
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/search"
"pipu/api/internal/server"
"pipu/api/internal/system"
)
@@ -78,6 +79,7 @@ func run() error {
}, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
"search": system.CheckerFunc(search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey).Healthy),
})
libStore := library.NewStore(pool)
@@ -86,6 +88,11 @@ func run() error {
scanner := library.NewScanner(libStore, prober, board, log)
libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken),
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
indexer := search.NewIndexer(pool, meili, log)
scanner.AfterScan = func(context.Context) { indexer.Trigger() }
libSvc.OnCatalogChanged = indexer.Trigger
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
go libSvc.Restore(ctx)
go libSvc.RunSchedule(ctx)
@@ -95,6 +102,7 @@ func run() error {
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore),
Search: search.NewHandler(meili, libStore, indexer, log),
}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
+501 -119
View File
@@ -101,6 +101,14 @@ const (
Island UiPrefsWebPlayer = "island"
)
// Defines values for SearchParamsType.
const (
Albums SearchParamsType = "albums"
All SearchParamsType = "all"
Artists SearchParamsType = "artists"
Tracks SearchParamsType = "tracks"
)
// AdminUser defines model for AdminUser.
type AdminUser struct {
CreatedAt time.Time `json:"created_at"`
@@ -403,6 +411,24 @@ type ScanRequest struct {
Full *bool `json:"full,omitempty"`
}
// SearchResults defines model for SearchResults.
type SearchResults struct {
Albums []AlbumSummary `json:"albums"`
Artists []ArtistRef `json:"artists"`
// CorrectedQuery Запрос в другой раскладке, если по исходному ничего не нашлось
CorrectedQuery *string `json:"corrected_query"`
NextCursor *string `json:"next_cursor"`
// Totals Оценка общего числа совпадений
Totals struct {
Albums int `json:"albums"`
Artists int `json:"artists"`
Tracks int `json:"tracks"`
} `json:"totals"`
Tracks []Track `json:"tracks"`
}
// ServerInfo defines model for ServerInfo.
type ServerInfo struct {
ApiVersion string `json:"api_version"`
@@ -642,6 +668,24 @@ type RefreshTokensParams struct {
PipuRt *string `form:"pipu_rt,omitempty" json:"pipu_rt,omitempty"`
}
// SearchParams defines parameters for Search.
type SearchParams struct {
Q string `form:"q" json:"q"`
Type *SearchParamsType `form:"type,omitempty" json:"type,omitempty"`
Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"`
// Cursor Непрозрачный курсор из `next_cursor` предыдущей страницы
Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"`
}
// SearchParamsType defines parameters for Search.
type SearchParamsType string
// SearchSuggestParams defines parameters for SearchSuggest.
type SearchSuggestParams struct {
Q string `form:"q" json:"q"`
}
// CreateSourceJSONRequestBody defines body for CreateSource for application/json ContentType.
type CreateSourceJSONRequestBody = SourceInput
@@ -692,6 +736,9 @@ type ServerInterface interface {
// Состояние библиотеки (admin, moderator)
// (GET /api/v1/admin/library/status)
GetLibraryStatus(w http.ResponseWriter, r *http.Request)
// Пересобрать поисковые индексы (admin)
// (POST /api/v1/admin/search/reindex)
ReindexSearch(w http.ResponseWriter, r *http.Request)
// Хранилища (admin)
// (GET /api/v1/admin/sources)
ListSources(w http.ResponseWriter, r *http.Request)
@@ -776,6 +823,12 @@ type ServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Страница поиска
// (GET /api/v1/search)
Search(w http.ResponseWriter, r *http.Request, params SearchParams)
// Выпадающий список поиска — до 3 исполнителей, 3 альбомов, 5 треков
// (GET /api/v1/search/suggest)
SearchSuggest(w http.ResponseWriter, r *http.Request, params SearchSuggestParams)
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
GetServerInfo(w http.ResponseWriter, r *http.Request)
@@ -818,6 +871,12 @@ func (_ Unimplemented) GetLibraryStatus(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNotImplemented)
}
// Пересобрать поисковые индексы (admin)
// (POST /api/v1/admin/search/reindex)
func (_ Unimplemented) ReindexSearch(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Хранилища (admin)
// (GET /api/v1/admin/sources)
func (_ Unimplemented) ListSources(w http.ResponseWriter, r *http.Request) {
@@ -986,6 +1045,18 @@ func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, i
w.WriteHeader(http.StatusNotImplemented)
}
// Страница поиска
// (GET /api/v1/search)
func (_ Unimplemented) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
w.WriteHeader(http.StatusNotImplemented)
}
// Выпадающий список поиска — до 3 исполнителей, 3 альбомов, 5 треков
// (GET /api/v1/search/suggest)
func (_ Unimplemented) SearchSuggest(w http.ResponseWriter, r *http.Request, params SearchSuggestParams) {
w.WriteHeader(http.StatusNotImplemented)
}
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) {
@@ -1154,6 +1225,26 @@ func (siw *ServerInterfaceWrapper) GetLibraryStatus(w http.ResponseWriter, r *ht
handler.ServeHTTP(w, r)
}
// ReindexSearch operation middleware
func (siw *ServerInterfaceWrapper) ReindexSearch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ReindexSearch(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListSources operation middleware
func (siw *ServerInterfaceWrapper) ListSources(w http.ResponseWriter, r *http.Request) {
@@ -1924,6 +2015,110 @@ func (siw *ServerInterfaceWrapper) RevokeMySession(w http.ResponseWriter, r *htt
handler.ServeHTTP(w, r)
}
// Search operation middleware
func (siw *ServerInterfaceWrapper) Search(w http.ResponseWriter, r *http.Request) {
var err error
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
// Parameter object where we will unmarshal all parameters from the context
var params SearchParams
// ------------- Required query parameter "q" -------------
if paramValue := r.URL.Query().Get("q"); paramValue != "" {
} else {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"})
return
}
err = runtime.BindQueryParameter("form", true, true, "q", r.URL.Query(), &params.Q)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err})
return
}
// ------------- Optional query parameter "type" -------------
err = runtime.BindQueryParameter("form", true, false, "type", r.URL.Query(), &params.Type)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err})
return
}
// ------------- Optional query parameter "limit" -------------
err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), &params.Limit)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
return
}
// ------------- Optional query parameter "cursor" -------------
err = runtime.BindQueryParameter("form", true, false, "cursor", r.URL.Query(), &params.Cursor)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.Search(w, r, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// SearchSuggest operation middleware
func (siw *ServerInterfaceWrapper) SearchSuggest(w http.ResponseWriter, r *http.Request) {
var err error
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
// Parameter object where we will unmarshal all parameters from the context
var params SearchSuggestParams
// ------------- Required query parameter "q" -------------
if paramValue := r.URL.Query().Get("q"); paramValue != "" {
} else {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "q"})
return
}
err = runtime.BindQueryParameter("form", true, true, "q", r.URL.Query(), &params.Q)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "q", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SearchSuggest(w, r, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetServerInfo operation middleware
func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) {
@@ -2122,6 +2317,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/admin/library/status", wrapper.GetLibraryStatus)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/admin/search/reindex", wrapper.ReindexSearch)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/admin/sources", wrapper.ListSources)
})
@@ -2206,6 +2404,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/search", wrapper.Search)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/search/suggest", wrapper.SearchSuggest)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/server-info", wrapper.GetServerInfo)
})
@@ -2339,6 +2543,33 @@ func (response GetLibraryStatusdefaultApplicationProblemPlusJSONResponse) VisitG
return json.NewEncoder(w).Encode(response.Body)
}
type ReindexSearchRequestObject struct {
}
type ReindexSearchResponseObject interface {
VisitReindexSearchResponse(w http.ResponseWriter) error
}
type ReindexSearch202Response struct {
}
func (response ReindexSearch202Response) VisitReindexSearchResponse(w http.ResponseWriter) error {
w.WriteHeader(202)
return nil
}
type ReindexSearchdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response ReindexSearchdefaultApplicationProblemPlusJSONResponse) VisitReindexSearchResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ListSourcesRequestObject struct {
}
@@ -3147,6 +3378,64 @@ func (response RevokeMySessiondefaultApplicationProblemPlusJSONResponse) VisitRe
return json.NewEncoder(w).Encode(response.Body)
}
type SearchRequestObject struct {
Params SearchParams
}
type SearchResponseObject interface {
VisitSearchResponse(w http.ResponseWriter) error
}
type Search200JSONResponse SearchResults
func (response Search200JSONResponse) VisitSearchResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type SearchdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response SearchdefaultApplicationProblemPlusJSONResponse) VisitSearchResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type SearchSuggestRequestObject struct {
Params SearchSuggestParams
}
type SearchSuggestResponseObject interface {
VisitSearchSuggestResponse(w http.ResponseWriter) error
}
type SearchSuggest200JSONResponse SearchResults
func (response SearchSuggest200JSONResponse) VisitSearchSuggestResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type SearchSuggestdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response SearchSuggestdefaultApplicationProblemPlusJSONResponse) VisitSearchSuggestResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type GetServerInfoRequestObject struct {
}
@@ -3259,6 +3548,9 @@ type StrictServerInterface interface {
// Состояние библиотеки (admin, moderator)
// (GET /api/v1/admin/library/status)
GetLibraryStatus(ctx context.Context, request GetLibraryStatusRequestObject) (GetLibraryStatusResponseObject, error)
// Пересобрать поисковые индексы (admin)
// (POST /api/v1/admin/search/reindex)
ReindexSearch(ctx context.Context, request ReindexSearchRequestObject) (ReindexSearchResponseObject, error)
// Хранилища (admin)
// (GET /api/v1/admin/sources)
ListSources(ctx context.Context, request ListSourcesRequestObject) (ListSourcesResponseObject, error)
@@ -3343,6 +3635,12 @@ type StrictServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error)
// Страница поиска
// (GET /api/v1/search)
Search(ctx context.Context, request SearchRequestObject) (SearchResponseObject, error)
// Выпадающий список поиска — до 3 исполнителей, 3 альбомов, 5 треков
// (GET /api/v1/search/suggest)
SearchSuggest(ctx context.Context, request SearchSuggestRequestObject) (SearchSuggestResponseObject, error)
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error)
@@ -3488,6 +3786,30 @@ func (sh *strictHandler) GetLibraryStatus(w http.ResponseWriter, r *http.Request
}
}
// ReindexSearch operation middleware
func (sh *strictHandler) ReindexSearch(w http.ResponseWriter, r *http.Request) {
var request ReindexSearchRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.ReindexSearch(ctx, request.(ReindexSearchRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "ReindexSearch")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(ReindexSearchResponseObject); ok {
if err := validResponse.VisitReindexSearchResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ListSources operation middleware
func (sh *strictHandler) ListSources(w http.ResponseWriter, r *http.Request) {
var request ListSourcesRequestObject
@@ -4278,6 +4600,58 @@ func (sh *strictHandler) RevokeMySession(w http.ResponseWriter, r *http.Request,
}
}
// Search operation middleware
func (sh *strictHandler) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
var request SearchRequestObject
request.Params = params
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.Search(ctx, request.(SearchRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "Search")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(SearchResponseObject); ok {
if err := validResponse.VisitSearchResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// SearchSuggest operation middleware
func (sh *strictHandler) SearchSuggest(w http.ResponseWriter, r *http.Request, params SearchSuggestParams) {
var request SearchSuggestRequestObject
request.Params = params
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.SearchSuggest(ctx, request.(SearchSuggestRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SearchSuggest")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(SearchSuggestResponseObject); ok {
if err := validResponse.VisitSearchSuggestResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetServerInfo operation middleware
func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
var request GetServerInfoRequestObject
@@ -4379,125 +4753,133 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"AAA=",
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+53
View File
@@ -189,3 +189,56 @@ func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
}
return a, nil
}
// AlbumsByIDs — альбомы в порядке ids (недостающие пропускаются).
func (st *Store) AlbumsByIDs(ctx context.Context, ids []uuid.UUID) ([]AlbumRow, error) {
rows, err := st.pool.Query(ctx, albumSelect+" WHERE al.id = ANY($1)", ids)
if err != nil {
return nil, fmt.Errorf("library: albums: %w", err)
}
defer rows.Close()
byID := map[uuid.UUID]AlbumRow{}
for rows.Next() {
a, err := scanAlbum(rows)
if err != nil {
return nil, err
}
byID[a.ID] = a
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]AlbumRow, 0, len(ids))
for _, id := range ids {
if a, ok := byID[id]; ok {
out = append(out, a)
}
}
return out, nil
}
// ArtistsByIDs — исполнители в порядке ids.
func (st *Store) ArtistsByIDs(ctx context.Context, ids []uuid.UUID) ([]Ref, error) {
rows, err := st.pool.Query(ctx, `SELECT id, name FROM artists WHERE id = ANY($1)`, ids)
if err != nil {
return nil, fmt.Errorf("library: artists: %w", err)
}
refs, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (Ref, error) {
var a Ref
return a, r.Scan(&a.ID, &a.Name)
})
if err != nil {
return nil, fmt.Errorf("library: artists: %w", err)
}
byID := map[uuid.UUID]Ref{}
for _, a := range refs {
byID[a.ID] = a
}
out := make([]Ref, 0, len(ids))
for _, id := range ids {
if a, ok := byID[id]; ok {
out = append(out, a)
}
}
return out, nil
}
+3 -2
View File
@@ -315,7 +315,8 @@ func TrackDTO(t TrackRow) gen.Track {
}
}
func albumSummaryDTO(a AlbumRow) gen.AlbumSummary {
// AlbumSummaryDTO переводит альбом в схему AlbumSummary.
func AlbumSummaryDTO(a AlbumRow) gen.AlbumSummary {
return gen.AlbumSummary{
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: coverURL(a), CoverColors: colors(a.CoverColors),
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name}, TrackCount: a.TrackCount,
@@ -342,7 +343,7 @@ func (h *Handler) ListArtistAlbums(ctx context.Context, req gen.ListArtistAlbums
}
out := gen.ListArtistAlbums200JSONResponse{Items: make([]gen.AlbumSummary, 0, len(rows))}
for _, a := range rows {
out.Items = append(out.Items, albumSummaryDTO(a))
out.Items = append(out.Items, AlbumSummaryDTO(a))
}
return out, nil
}
+10 -1
View File
@@ -53,6 +53,9 @@ type Service struct {
cfg Config
log *slog.Logger
mu sync.Mutex // сериализует переподключение одного и того же источника
// OnCatalogChanged — каталог изменился не сканом (удалено хранилище): переиндексация поиска.
OnCatalogChanged func()
}
// NewService создаёт сервис.
@@ -337,7 +340,13 @@ func (s *Service) DeleteSource(ctx context.Context, id uuid.UUID) error {
if err := s.store.DeleteSource(ctx, id); err != nil {
return notFound(err)
}
return s.store.CleanupOrphans(ctx)
if err := s.store.CleanupOrphans(ctx); err != nil {
return err
}
if s.OnCatalogChanged != nil {
s.OnCatalogChanged()
}
return nil
}
// TestResult — итог проверки подключения.
@@ -39,6 +39,9 @@ type API struct {
StorageAgentURL string `env:"STORAGE_AGENT_URL" envDefault:"http://storage-agent:8090"`
InternalToken string `env:"INTERNAL_TOKEN"`
FFprobePath string `env:"FFPROBE_PATH" envDefault:"ffprobe"`
MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"`
MeiliMasterKey string `env:"MEILI_MASTER_KEY"`
}
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
+242
View File
@@ -0,0 +1,242 @@
package search
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Индексы Meilisearch.
const (
IndexArtists = "artists"
IndexAlbums = "albums"
IndexTracks = "tracks"
)
type indexSpec struct {
uid string
settings map[string]any
query string
doc func(pgx.Rows) (map[string]any, error)
}
var rankingRules = []string{"words", "typo", "proximity", "attribute", "sort", "exactness", "popularity:desc"}
var specs = []indexSpec{
{
uid: IndexArtists,
settings: map[string]any{
"searchableAttributes": []string{"name", "name_translit"},
"sortableAttributes": []string{"popularity"},
"rankingRules": rankingRules,
},
query: `SELECT a.id::text, a.name FROM artists a`,
doc: func(r pgx.Rows) (map[string]any, error) {
var id, name string
if err := r.Scan(&id, &name); err != nil {
return nil, err
}
return map[string]any{"id": id, "name": name, "name_translit": Translit(name), "popularity": 0}, nil
},
},
{
uid: IndexAlbums,
settings: map[string]any{
"searchableAttributes": []string{"title", "artist_name", "title_translit", "artist_translit"},
"filterableAttributes": []string{"year"},
"sortableAttributes": []string{"year", "popularity"},
"rankingRules": rankingRules,
},
query: `SELECT al.id::text, al.title, aa.name, al.year FROM albums al JOIN artists aa ON aa.id = al.album_artist_id`,
doc: func(r pgx.Rows) (map[string]any, error) {
var (
id, title, artist string
year *int
)
if err := r.Scan(&id, &title, &artist, &year); err != nil {
return nil, err
}
return map[string]any{
"id": id, "title": title, "artist_name": artist, "year": year, "popularity": 0,
"title_translit": Translit(title), "artist_translit": Translit(artist),
}, nil
},
},
{
uid: IndexTracks,
settings: map[string]any{
"searchableAttributes": []string{"title", "artist_names", "album_title", "title_translit", "artist_translit"},
"filterableAttributes": []string{"genres", "year", "available"},
"sortableAttributes": []string{"year", "popularity"},
"rankingRules": rankingRules,
},
query: `
SELECT t.id::text, t.title, al.title, COALESCE(t.year, al.year),
COALESCE((SELECT array_agg(a.name ORDER BY ta.position) FROM track_artists ta
JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id), '{}'),
COALESCE((SELECT array_agg(g.name) FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id), '{}'),
t.primary_file_id IS NOT NULL
FROM tracks t JOIN albums al ON al.id = t.album_id`,
doc: func(r pgx.Rows) (map[string]any, error) {
var (
id, title, album string
year *int
artists, genres []string
available bool
)
if err := r.Scan(&id, &title, &album, &year, &artists, &genres, &available); err != nil {
return nil, err
}
translit := make([]string, 0, len(artists))
for _, a := range artists {
translit = append(translit, Translit(a))
}
return map[string]any{
"id": id, "title": title, "album_title": album, "year": year, "artist_names": artists,
"genres": genres, "available": available, "popularity": 0,
"title_translit": Translit(title), "artist_translit": translit,
}, nil
},
},
}
const batchSize = 1000
// Indexer полностью пересобирает индексы из PostgreSQL. Новые индексы собираются рядом
// (`<uid>_next`) и подменяются через swap-indexes — поиск работает всё время.
type Indexer struct {
pool *pgxpool.Pool
meili *Meili
log *slog.Logger
mu sync.Mutex
running bool
again bool
// LastError — ошибка последней переиндексации (для статуса), пусто — успех.
lastErr error
lastDone time.Time
}
// NewIndexer создаёт индексатор.
func NewIndexer(pool *pgxpool.Pool, meili *Meili, log *slog.Logger) *Indexer {
return &Indexer{pool: pool, meili: meili, log: log}
}
// Trigger запускает переиндексацию в фоне. Если она уже идёт — ещё одна выполнится сразу после.
func (ix *Indexer) Trigger() {
ix.mu.Lock()
if ix.running {
ix.again = true
ix.mu.Unlock()
return
}
ix.running = true
ix.mu.Unlock()
go func() {
for {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
err := ix.Reindex(ctx)
cancel()
if err != nil {
ix.log.Error("search reindex failed", "err", err)
}
ix.mu.Lock()
ix.lastErr, ix.lastDone = err, time.Now()
if !ix.again {
ix.running = false
ix.mu.Unlock()
return
}
ix.again = false
ix.mu.Unlock()
}
}()
}
// Reindex пересобирает все индексы.
func (ix *Indexer) Reindex(ctx context.Context) error {
started := time.Now()
for _, s := range specs {
if err := ix.rebuild(ctx, s); err != nil {
return err
}
}
ix.log.Info("search reindexed", "duration_ms", time.Since(started).Milliseconds())
return nil
}
func (ix *Indexer) rebuild(ctx context.Context, s indexSpec) error {
next := s.uid + "_next"
if err := ix.meili.call(ctx, http.MethodDelete, "/indexes/"+next, nil); err != nil && !errors.Is(err, ErrMeiliNotFound) {
return err
}
if err := ix.meili.call(ctx, http.MethodPost, "/indexes", map[string]any{"uid": next, "primaryKey": "id"}); err != nil {
return err
}
if err := ix.meili.call(ctx, http.MethodPatch, "/indexes/"+next+"/settings", s.settings); err != nil {
return err
}
rows, err := ix.pool.Query(ctx, s.query)
if err != nil {
return fmt.Errorf("search: %s: %w", s.uid, err)
}
batch := make([]map[string]any, 0, batchSize)
flush := func() error {
if len(batch) == 0 {
return nil
}
err := ix.meili.call(ctx, http.MethodPost, "/indexes/"+next+"/documents", batch)
batch = batch[:0]
return err
}
for rows.Next() {
doc, err := s.doc(rows)
if err != nil {
rows.Close()
return fmt.Errorf("search: %s: %w", s.uid, err)
}
batch = append(batch, doc)
if len(batch) == batchSize {
if err := flush(); err != nil {
rows.Close()
return err
}
}
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("search: %s: %w", s.uid, err)
}
if err := flush(); err != nil {
return err
}
// swap-indexes требует, чтобы оба индекса существовали.
if err := ix.meili.do(ctx, http.MethodGet, "/indexes/"+s.uid, nil, nil); errors.Is(err, ErrMeiliNotFound) {
if err := ix.meili.call(ctx, http.MethodPost, "/indexes", map[string]any{"uid": s.uid, "primaryKey": "id"}); err != nil {
return err
}
} else if err != nil {
return err
}
if err := ix.meili.call(ctx, http.MethodPost, "/swap-indexes", []map[string]any{{"indexes": []string{s.uid, next}}}); err != nil {
return err
}
return ix.meili.call(ctx, http.MethodDelete, "/indexes/"+next, nil)
}
// State — состояние индексатора для админки.
func (ix *Indexer) State() (running bool, lastDone time.Time, lastErr error) {
ix.mu.Lock()
defer ix.mu.Unlock()
return ix.running, ix.lastDone, ix.lastErr
}
+153
View File
@@ -0,0 +1,153 @@
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
}
+220
View File
@@ -0,0 +1,220 @@
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
}
+122
View File
@@ -0,0 +1,122 @@
// Package search — поиск по каталогу через Meilisearch (docs/library-and-storage.md#поиск).
package search
import (
"strings"
"unicode"
)
// Кириллица → латиница (упрощённая ГОСТ-подобная схема: «Кино» → «kino», «Щедрин» → «shchedrin»).
var cyrToLat = map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i",
'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t",
'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "",
'э': "e", 'ю': "yu", 'я': "ya", 'і': "i", 'ї': "yi", 'є': "ye", 'ґ': "g",
}
// Латиница → кириллица: сначала длинные сочетания.
var latToCyr = []struct{ lat, cyr string }{
{"shch", "щ"}, {"zh", "ж"}, {"kh", "х"}, {"ts", "ц"}, {"ch", "ч"}, {"sh", "ш"}, {"yu", "ю"}, {"ya", "я"},
{"yo", "ё"}, {"a", "а"}, {"b", "б"}, {"v", "в"}, {"g", "г"}, {"d", "д"}, {"e", "е"}, {"z", "з"},
{"i", "и"}, {"y", "й"}, {"k", "к"}, {"l", "л"}, {"m", "м"}, {"n", "н"}, {"o", "о"}, {"p", "п"},
{"r", "р"}, {"s", "с"}, {"t", "т"}, {"u", "у"}, {"f", "ф"}, {"h", "х"}, {"c", "к"}, {"w", "в"},
{"x", "кс"}, {"q", "к"}, {"j", "дж"},
}
// Translit — вариант строки в другой письменности для поля *_translit: кириллица → латиница,
// латиница → кириллица. Смешанные строки переводятся посимвольно. Пустая строка — перевод не нужен.
func Translit(s string) string {
s = strings.ToLower(s)
hasCyr, hasLat := false, false
for _, r := range s {
switch {
case unicode.Is(unicode.Cyrillic, r):
hasCyr = true
case r < unicode.MaxASCII && unicode.IsLetter(r):
hasLat = true
}
}
var b strings.Builder
switch {
case hasCyr:
for _, r := range s {
if v, ok := cyrToLat[r]; ok {
b.WriteString(v)
} else {
b.WriteRune(r)
}
}
case hasLat:
for i := 0; i < len(s); {
matched := false
for _, p := range latToCyr {
if strings.HasPrefix(s[i:], p.lat) {
b.WriteString(p.cyr)
i += len(p.lat)
matched = true
break
}
}
if !matched {
b.WriteByte(s[i])
i++
}
}
default:
return ""
}
return b.String()
}
// Раскладки ЙЦУКЕН ↔ QWERTY: та же клавиша.
const (
qwerty = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./~QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?"
jcuken = "ёйцукенгшщзхъфывапролджэячсмитьбю.ЁЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,"
)
var (
toRu = map[rune]rune{}
toEn = map[rune]rune{}
)
func init() {
q, j := []rune(qwerty), []rune(jcuken)
for i := range q {
toRu[q[i]] = j[i]
toEn[j[i]] = q[i]
}
}
// SwitchLayout — запрос, набранный не в той раскладке: «ghbdtn» → «привет», «руддщ» → «hello».
// Направление выбирается по большинству букв. Пустая строка — менять нечего.
func SwitchLayout(s string) string {
lat, cyr := 0, 0
for _, r := range s {
switch {
case unicode.Is(unicode.Cyrillic, r):
cyr++
case r < unicode.MaxASCII && unicode.IsLetter(r):
lat++
}
}
if lat == 0 && cyr == 0 {
return ""
}
m := toRu
if cyr > lat {
m = toEn
}
var b strings.Builder
for _, r := range s {
if v, ok := m[r]; ok {
b.WriteRune(v)
} else {
b.WriteRune(r)
}
}
out := b.String()
if out == s {
return ""
}
return out
}
@@ -0,0 +1,22 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTranslit(t *testing.T) {
assert.Equal(t, "kino", Translit("Кино"))
assert.Equal(t, "shchedrin", Translit("Щедрин"))
assert.Equal(t, "кино", Translit("Kino"))
assert.Equal(t, "щедрин", Translit("shchedrin"))
assert.Equal(t, "", Translit("1984"))
}
func TestSwitchLayout(t *testing.T) {
assert.Equal(t, "привет", SwitchLayout("ghbdtn"))
assert.Equal(t, "hello", SwitchLayout("руддщ"))
assert.Equal(t, "кино", SwitchLayout("rbyj"))
assert.Equal(t, "", SwitchLayout("123"))
}
+5 -1
View File
@@ -12,6 +12,7 @@ import (
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/search"
"pipu/api/internal/system"
)
@@ -22,6 +23,7 @@ type Deps struct {
Auth *auth.Handler
// Library — хранилища, сканы и каталог.
Library *library.Handler
Search *search.Handler
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
AuthService *auth.Service
}
@@ -33,12 +35,14 @@ type (
systemHandler = system.Handler
authHandler = auth.Handler
libraryHandler = library.Handler
searchHandler = search.Handler
)
type handlers struct {
*systemHandler
*authHandler
*libraryHandler
*searchHandler
}
var _ gen.StrictServerInterface = handlers{}
@@ -58,7 +62,7 @@ func NewRouter(d Deps) http.Handler {
r.NotFound(httpx.NotFound)
r.MethodNotAllowed(httpx.MethodNotAllowed)
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library}, nil, gen.StrictHTTPServerOptions{
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search}, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log),
})