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
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:
1 parent
c583991d14
commit
c1160cccd3
16 files changed
+1590
-124
No files matched your search
@@ -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`):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(), ¶ms.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(), ¶ms.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(), ¶ms.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(), ¶ms.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(), ¶ms.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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user