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

+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
}