- WaveButton: TypeScript port of the prototype's canvas animation (orb and liquid), breathing at rest, pulsing to the track's BPM and energy in the cover palette, following the cursor, pressing with a spring; reduced motion and hidden tabs slow or stop it - home: the wave in the centre with activity and mood chips; changing them while playing rebuilds the upcoming tracks without stopping - player wave mode: asks for the next batch when two tracks are left, sends complete / skip / early skip / like / dislike as feedback and refreshes the queue right after a reaction or an early skip - tracks expose bpm, energy and loudness once analysed; library page shows analysis progress and 7-day wave metrics; wave style setting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
7.9 KiB
Go
253 lines
7.9 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
|
|
// Признаки анализа; Analyzed = false — трек ещё не проанализирован.
|
|
Analyzed bool
|
|
BPM *float32
|
|
Energy *float32
|
|
LoudnessLUFS *float32
|
|
}
|
|
|
|
// 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'),
|
|
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.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
|
|
}
|
|
|
|
// 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
|
|
}
|