ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
8.7 KiB
Go
264 lines
8.7 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
|
|
ExternalCoverURL *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
|
|
// Признаки анализа; Analyzed = false — трек ещё не проанализирован.
|
|
Analyzed bool
|
|
BPM *float32
|
|
Energy *float32
|
|
LoudnessLUFS *float32
|
|
}
|
|
|
|
// ArtistRow — исполнитель со счётчиками.
|
|
type ArtistRow struct {
|
|
ImageURL *string
|
|
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.genius_cover_url, (SELECT NULLIF(content->>'song_image_url','') FROM genius_tracks WHERE track_id=t.id)), 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'),
|
|
tf.track_id IS NOT NULL, tf.bpm, tf.energy, tf.loudness_lufs
|
|
FROM tracks t
|
|
LEFT JOIN track_features tf ON tf.track_id = t.id
|
|
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.ExternalCoverURL, &t.Album.CoverColors,
|
|
&t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available, &t.Analyzed, &t.BPM, &t.Energy,
|
|
&t.LoudnessLUFS)
|
|
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
|
|
}
|
|
|
|
// ArtistTopTracks — треки исполнителя (основной или приглашённый), самые слушаемые всеми пользователями первыми.
|
|
func (st *Store) ArtistTopTracks(ctx context.Context, artistID uuid.UUID, limit int) ([]TrackRow, error) {
|
|
return st.queryTracks(ctx, `
|
|
WHERE t.id IN (SELECT track_id FROM track_artists WHERE artist_id = $1)
|
|
ORDER BY COALESCE((SELECT sum(s.play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0) DESC,
|
|
t.created_at DESC
|
|
LIMIT $2`, artistID, limit)
|
|
}
|
|
|
|
// 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, al.genius_cover_url, 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.ExternalCoverURL, &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, a.genius_image_url,
|
|
(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.ImageURL, &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
|
|
}
|