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 }