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 }