Files
pipu-music/services/api/internal/library/catalog.go
T
mrleo1nid 82bf015283
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
Import missing artist portraits from uploaded album folders
2026-10-30 10:46:07 +03:00

265 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
ImagePath *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, a.image_path,
(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.ImagePath, &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
}