Files
pipu-music/services/api/internal/library/catalog.go
T
mrleo1nidandClaude Opus 5.5 c1160cccd3
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
feat(api): search with Meilisearch (M2)
- 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>
2026-10-20 06:51:50 +03:00

245 lines
7.5 KiB
Go

package library
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// Ref — ссылка на исполнителя.
type Ref struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// AlbumRow — альбом для карточек и страниц.
type AlbumRow struct {
ID uuid.UUID
Title string
Year *int
CoverPath *string
CoverColors []string
Artist Ref
TrackCount int
}
// TrackRow — трек с исполнителями, альбомом и жанрами.
type TrackRow struct {
ID uuid.UUID
Title string
DurationMs int
DiscNo *int
TrackNo *int
Artists []Ref
Album AlbumRow
Genres []string
Available bool
}
// ArtistRow — исполнитель со счётчиками.
type ArtistRow struct {
ID uuid.UUID
Name string
AlbumCount int
TrackCount int
}
// trackSelect — общий SELECT треков; доступен трек, у которого есть основной файл
// в подключённом хранилище.
const trackSelect = `
SELECT t.id, t.title, t.duration_ms, t.disc_no, t.track_no,
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'name', a.name) ORDER BY ta.role DESC, ta.position)
FROM track_artists ta JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id), '[]'),
al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
COALESCE((SELECT array_agg(g.name ORDER BY g.name) FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id), '{}'),
EXISTS (SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.id = t.primary_file_id AND s.status = 'mounted')
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id`
func scanTrack(row pgx.Row) (TrackRow, error) {
var (
t TrackRow
artists []byte
)
err := row.Scan(&t.ID, &t.Title, &t.DurationMs, &t.DiscNo, &t.TrackNo, &artists,
&t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.CoverColors,
&t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available)
if errors.Is(err, pgx.ErrNoRows) {
return TrackRow{}, ErrNotFound
}
if err != nil {
return TrackRow{}, fmt.Errorf("library: scan track: %w", err)
}
if err := json.Unmarshal(artists, &t.Artists); err != nil {
return TrackRow{}, fmt.Errorf("library: track artists: %w", err)
}
return t, nil
}
func (st *Store) queryTracks(ctx context.Context, where string, args ...any) ([]TrackRow, error) {
rows, err := st.pool.Query(ctx, trackSelect+" "+where, args...)
if err != nil {
return nil, fmt.Errorf("library: tracks: %w", err)
}
defer rows.Close()
var out []TrackRow
for rows.Next() {
t, err := scanTrack(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// Track — трек по id.
func (st *Store) Track(ctx context.Context, id uuid.UUID) (TrackRow, error) {
return scanTrack(st.pool.QueryRow(ctx, trackSelect+" WHERE t.id = $1", id))
}
// TracksByIDs — треки в порядке ids (недостающие пропускаются).
func (st *Store) TracksByIDs(ctx context.Context, ids []uuid.UUID) ([]TrackRow, error) {
rows, err := st.queryTracks(ctx, "WHERE t.id = ANY($1)", ids)
if err != nil {
return nil, err
}
byID := make(map[uuid.UUID]TrackRow, len(rows))
for _, t := range rows {
byID[t.ID] = t
}
out := make([]TrackRow, 0, len(ids))
for _, id := range ids {
if t, ok := byID[id]; ok {
out = append(out, t)
}
}
return out, nil
}
// AlbumTracks — треки альбома по дискам и номерам.
func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow, error) {
return st.queryTracks(ctx, "WHERE t.album_id = $1 ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST, t.title", albumID)
}
const albumSelect = `
SELECT al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name,
(SELECT count(*) FROM tracks t WHERE t.album_id = al.id)
FROM albums al JOIN artists aa ON aa.id = al.album_artist_id`
func scanAlbum(row pgx.Row) (AlbumRow, error) {
var a AlbumRow
err := row.Scan(&a.ID, &a.Title, &a.Year, &a.CoverPath, &a.CoverColors, &a.Artist.ID, &a.Artist.Name, &a.TrackCount)
if errors.Is(err, pgx.ErrNoRows) {
return AlbumRow{}, ErrNotFound
}
if err != nil {
return AlbumRow{}, fmt.Errorf("library: scan album: %w", err)
}
return a, nil
}
// Album — альбом по id.
func (st *Store) Album(ctx context.Context, id uuid.UUID) (AlbumRow, error) {
return scanAlbum(st.pool.QueryRow(ctx, albumSelect+" WHERE al.id = $1", id))
}
// ArtistAlbums — альбомы исполнителя и сборники, где есть его треки; новые сверху.
func (st *Store) ArtistAlbums(ctx context.Context, artistID uuid.UUID) ([]AlbumRow, error) {
rows, err := st.pool.Query(ctx, albumSelect+`
WHERE al.album_artist_id = $1 OR EXISTS (
SELECT 1 FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
WHERE t.album_id = al.id AND ta.artist_id = $1)
ORDER BY (al.album_artist_id = $1) DESC, al.year DESC NULLS LAST, al.title`, artistID)
if err != nil {
return nil, fmt.Errorf("library: artist albums: %w", err)
}
defer rows.Close()
var out []AlbumRow
for rows.Next() {
a, err := scanAlbum(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// Artist — исполнитель со счётчиками альбомов и треков.
func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
var a ArtistRow
err := st.pool.QueryRow(ctx, `
SELECT a.id, a.name,
(SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id),
(SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id)
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.AlbumCount, &a.TrackCount)
if errors.Is(err, pgx.ErrNoRows) {
return ArtistRow{}, ErrNotFound
}
if err != nil {
return ArtistRow{}, fmt.Errorf("library: artist: %w", err)
}
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
}