feat: taste onboarding for new listeners (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
- GET /onboarding offers the library's top artists (with album covers) and genres and says whether to show it (not done and < 10 plays); PUT /me/onboarding stores the choice or a skip (migration 00009). - The recommender seeds the taste from it while the user has fewer than 30 scored tracks: chosen artists' tracks +1, chosen genres +0.5. - Web: onboarding replaces the wave on the home page until done or skipped; artist grid with covers, genre chips, at least 3 artists. - e2e journey and the recommender DB test cover it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff0aafdb1e
commit
fb07f962eb
14 files changed
+1137
-238
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package wave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель
|
||||
// берёт их как начальный вкус, пока своей истории мало. Пропуск сохраняется пустым выбором.
|
||||
|
||||
const (
|
||||
onboardingArtists = 40
|
||||
onboardingGenres = 24
|
||||
// История, после которой онбординг уже не предлагается: вкус и так виден по прослушиваниям.
|
||||
onboardingMinHistory = 10
|
||||
)
|
||||
|
||||
// GetOnboarding реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestObject) (gen.GetOnboardingResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := gen.GetOnboarding200JSONResponse{}
|
||||
var done bool
|
||||
var plays int
|
||||
if err := h.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM user_profiles_rec WHERE user_id = $1 AND onboarding IS NOT NULL),
|
||||
(SELECT count(*) FROM user_track_stats WHERE user_id = $1)`, p.UserID).Scan(&done, &plays); err != nil {
|
||||
return nil, fmt.Errorf("wave: onboarding state: %w", err)
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(ctx, `
|
||||
SELECT a.id, a.name, count(*) AS n,
|
||||
(SELECT al.id FROM albums al WHERE al.album_artist_id = a.id
|
||||
ORDER BY (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) DESC, al.id LIMIT 1)
|
||||
FROM artists a JOIN track_artists ta ON ta.artist_id = a.id AND ta.role = 'main'
|
||||
GROUP BY a.id ORDER BY n DESC, a.name LIMIT $1`, onboardingArtists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
|
||||
}
|
||||
type artist struct {
|
||||
id, album *uuid.UUID
|
||||
name string
|
||||
n int
|
||||
}
|
||||
var artists []artist
|
||||
for rows.Next() {
|
||||
var a artist
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id, &a.name, &a.n, &a.album); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
|
||||
}
|
||||
a.id = &id
|
||||
artists = append(artists, a)
|
||||
}
|
||||
rows.Close()
|
||||
dto := h.dto()
|
||||
for _, a := range artists {
|
||||
item := struct {
|
||||
CoverColors []string `json:"cover_colors"`
|
||||
CoverUrl *string `json:"cover_url"`
|
||||
Id uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackCount int `json:"track_count"`
|
||||
}{Id: *a.id, Name: a.name, TrackCount: a.n, CoverColors: []string{}}
|
||||
if a.album != nil {
|
||||
if al, err := h.catalog.Album(ctx, *a.album); err == nil {
|
||||
sum := dto.AlbumSummary(al)
|
||||
item.CoverUrl, item.CoverColors = sum.CoverUrl, sum.CoverColors
|
||||
}
|
||||
}
|
||||
out.Artists = append(out.Artists, item)
|
||||
}
|
||||
|
||||
grows, err := h.pool.Query(ctx, `
|
||||
SELECT g.name, count(DISTINCT tg.track_id) AS n FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
|
||||
GROUP BY g.name ORDER BY n DESC, g.name LIMIT $1`, onboardingGenres)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
|
||||
}
|
||||
for grows.Next() {
|
||||
var g struct {
|
||||
Name string `json:"name"`
|
||||
TrackCount int `json:"track_count"`
|
||||
}
|
||||
if err := grows.Scan(&g.Name, &g.TrackCount); err != nil {
|
||||
grows.Close()
|
||||
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
|
||||
}
|
||||
out.Genres = append(out.Genres, g)
|
||||
}
|
||||
grows.Close()
|
||||
if out.Artists == nil {
|
||||
out.Artists = []struct {
|
||||
CoverColors []string `json:"cover_colors"`
|
||||
CoverUrl *string `json:"cover_url"`
|
||||
Id uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackCount int `json:"track_count"`
|
||||
}{}
|
||||
}
|
||||
if out.Genres == nil {
|
||||
out.Genres = []struct {
|
||||
Name string `json:"name"`
|
||||
TrackCount int `json:"track_count"`
|
||||
}{}
|
||||
}
|
||||
out.Required = !done && plays < onboardingMinHistory && len(out.Artists) > 0
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveOnboarding реализует gen.StrictServerInterface.
|
||||
func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequestObject) (gen.SaveOnboardingResponseObject, error) {
|
||||
p, err := principal(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := req.Body.ArtistIds
|
||||
if ids == nil {
|
||||
ids = []uuid.UUID{}
|
||||
}
|
||||
genres := req.Body.Genres
|
||||
if genres == nil {
|
||||
genres = []string{}
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres})
|
||||
if _, err := h.pool.Exec(ctx, `
|
||||
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2, now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding, updated_at = now()`,
|
||||
p.UserID, raw); err != nil {
|
||||
return nil, fmt.Errorf("wave: save onboarding: %w", err)
|
||||
}
|
||||
return gen.SaveOnboarding204Response{}, nil
|
||||
}
|
||||
Reference in new issue
Block a user