feat(home): populate the home page with start-listening shelves

The sidebar had a Home item that fell through to "Coming soon". What
was missing was not another view of the library — four of those exist,
sorted and complete — but the opposite: a complete, sorted library is
exactly what gives you nothing to play, because every entry point into
it is alphabetical and identical every time you open the app.

So a shelf is a *reason*, not a filter. Each one answers a different
question you might be asking when you do not know what you want (what
was I listening to, what is new, what do I keep coming back to, what
have I forgotten, what fits, what would I never pick myself) and each
says which question it answered — a row of covers with no explanation
is just another grid.

Two consequences run through it. Shelves are built from what the user
actually did — play counts, last played, import order — with random
sampling only where there is no signal to use, so randomness is the
fallback rather than the design. And a shelf with nothing behind it is
omitted instead of rendered empty: a fresh library legitimately gets
three, and an empty row labelled "on repeat" would be a lie.

The queries return album ids and nothing else, joined back to
GetAllAlbumsWithDetails in Go, so the album projection keeps having one
definition rather than one per shelf.
This commit is contained in:
2026-08-11 01:15:34 -04:00
parent 62bb40fc4d
commit ff687f0bd9
12 changed files with 1838 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
// Package home builds the "start listening" shelves on the home page.
//
// The problem the home page solves is not "show the library" — four
// other views already do that, sorted and complete. It is the opposite:
// a complete, sorted library is exactly what gives you nothing to play,
// because every entry point into it is alphabetical and therefore
// identical every time you open the app.
//
// So a shelf here is a *reason*, not a filter. Each one answers a
// different question the user might be asking when they do not know
// what they want — what was I listening to, what is new, what do I keep
// coming back to, what have I forgotten, what fits the mood, what would
// I never pick myself — and each says which question it answered, since
// a row of covers with no explanation is just another grid.
//
// Two consequences of that framing show up throughout:
//
// - Shelves are built from what the user actually did (play counts,
// last played, import order) with random sampling only where there
// is no signal to use. Randomness is the fallback, not the design.
// - A shelf with nothing behind it is omitted rather than rendered
// empty. A fresh library has no history, so its home page is
// legitimately three shelves, and lying about that with empty rows
// labelled "on repeat" would be worse than showing fewer.
package home
import (
"context"
"log/slog"
"math/rand/v2"
"strings"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/library"
)
// Kind identifies what a shelf is built from, so the frontend can pick
// an icon and the e2e suite can assert on a shelf without matching
// display copy.
type Kind string
// Shelf kinds.
const (
KindRecentlyPlayed Kind = "recently-played"
KindRecentlyAdded Kind = "recently-added"
KindMostPlayed Kind = "most-played"
KindUnplayed Kind = "unplayed"
KindStale Kind = "stale"
KindArtist Kind = "artist"
KindGenre Kind = "genre"
KindRandom Kind = "random"
)
// Shelf is one horizontal row on the home page.
type Shelf struct {
// ID is stable within a response, for list keying.
ID string `json:"id"`
Kind Kind `json:"kind"`
// Title is the row heading.
Title string `json:"title"`
// Subtitle says why these albums are here. It is not decoration:
// without it a shelf is indistinguishable from a random grid.
Subtitle string `json:"subtitle"`
Albums []library.Album `json:"albums"`
}
// Shelf sizing.
const (
// shelfSize is how many albums one row holds. Wide enough to be
// worth scrolling, small enough that every shelf is a considered
// selection rather than a dump of the library.
shelfSize = 12
// maxGenreShelves bounds how many genre rows appear, so a
// heavily-tagged library does not turn the home page into the
// genres view.
maxGenreShelves = 2
// genreCandidates is how many top genres to draw the genre shelves
// from. Sampling from a pool rather than taking the top two is
// what stops the same two genres appearing forever.
genreCandidates = 8
// staleWindow is how long an album must go unplayed to count as
// forgotten. Six months is past "I listened to that recently" for
// almost everyone without reaching back to things they no longer
// own.
staleWindow = "-6 months"
// artistShelfMin is the fewest albums by one artist worth a shelf
// of their own.
artistShelfMin = 3
)
// Library is the album data the shelves are rendered from. Narrow on
// purpose: the home page needs one list of albums, not the library
// package.
type Library interface {
GetAllAlbums() ([]library.Album, error)
}
// Service builds the home page's shelves.
type Service struct {
logger *slog.Logger
db *database.DB
lib Library
}
// NewService builds the home service.
func NewService(
logger *slog.Logger,
db *database.DB,
lib Library,
) *Service {
return &Service{logger: logger, db: db, lib: lib}
}
// GetShelves returns the home page's rows, in display order.
//
// It is a single call rather than one per shelf because the shelves
// share an album lookup and because the page has nothing useful to
// render until it knows which rows exist — a page that pops rows in one
// at a time reflows under the user's cursor.
func (s *Service) GetShelves() ([]Shelf, error) {
ctx := s.db.Ctx
if ctx == nil {
ctx = context.Background()
}
albums, err := s.lib.GetAllAlbums()
if err != nil {
return nil, err
}
if len(albums) == 0 {
return []Shelf{}, nil
}
byID := make(map[int64]library.Album, len(albums))
for _, album := range albums {
byID[album.ID] = album
}
shelves := make([]Shelf, 0, 8) //nolint:mnd // rough capacity hint
add := func(shelf Shelf, ok bool) {
if ok && len(shelf.Albums) > 0 {
shelves = append(shelves, shelf)
}
}
add(s.recentlyPlayed(ctx, byID))
add(s.recentlyAdded(ctx, byID))
add(s.mostPlayed(ctx, byID))
add(s.favouriteArtist(ctx, albums))
for _, shelf := range s.genreShelves(ctx, byID) {
add(shelf, true)
}
add(s.forgotten(ctx, byID))
add(s.random(ctx, byID))
return shelves, nil
}
// resolve turns album ids into albums, dropping any the library no
// longer has (a scan can remove an album between the two queries).
func resolve(ids []int64, byID map[int64]library.Album) []library.Album {
out := make([]library.Album, 0, len(ids))
for _, id := range ids {
if album, ok := byID[id]; ok {
out = append(out, album)
}
}
return out
}
func (s *Service) recentlyPlayed(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeRecentlyPlayedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: recently played", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "recently-played",
Kind: KindRecentlyPlayed,
Title: "Pick up where you left off",
Subtitle: "The last albums you played",
Albums: resolve(ids, byID),
}, true
}
func (s *Service) recentlyAdded(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeRecentlyAddedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: recently added", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "recently-added",
Kind: KindRecentlyAdded,
Title: "Fresh in your library",
Subtitle: "Most recently added",
Albums: resolve(ids, byID),
}, true
}
func (s *Service) mostPlayed(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeMostPlayedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: most played", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "most-played",
Kind: KindMostPlayed,
Title: "On repeat",
Subtitle: "What you play the most",
Albums: resolve(ids, byID),
}, true
}
// forgotten is two shelves' worth of intent in one row: albums played
// long ago, and — for a library with no history at all — albums never
// played. Both answer "what am I ignoring", which is the shelf a large
// library benefits from most.
func (s *Service) forgotten(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
stale, err := s.db.ReadQueries.HomeStaleAlbums(
ctx,
sqlcgen.HomeStaleAlbumsParams{Datetime: staleWindow, Limit: shelfSize},
)
if err != nil {
s.logger.Warn("home: stale albums", "error", err)
stale = nil
}
if len(stale) > 0 {
return Shelf{
ID: "forgotten",
Kind: KindStale,
Title: "You haven't played this in a while",
Subtitle: "Last played over six months ago",
Albums: resolve(stale, byID),
}, true
}
unplayed, err := s.db.ReadQueries.HomeUnplayedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: unplayed albums", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "forgotten",
Kind: KindUnplayed,
Title: "Never played",
Subtitle: "In your library, still unheard",
Albums: resolve(unplayed, byID),
}, true
}
func (s *Service) random(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeRandomAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: random albums", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "random",
Kind: KindRandom,
Title: "Take a chance",
Subtitle: "A handful of albums at random",
Albums: resolve(ids, byID),
}, true
}
// favouriteArtist builds a shelf around whoever the user plays most,
// which is the one recommendation here that reads as personal rather
// than statistical.
func (s *Service) favouriteArtist(
ctx context.Context,
albums []library.Album,
) (Shelf, bool) {
rows, err := s.db.ReadQueries.HomeTopArtists(ctx, artistPoolSize)
if err != nil {
s.logger.Warn("home: top artists", "error", err)
return Shelf{}, false
}
// Sampling from the top few rather than always taking first place
// keeps the shelf from being a permanent fixture about one artist.
rand.Shuffle(len(rows), func(i, j int) {
rows[i], rows[j] = rows[j], rows[i]
})
for _, row := range rows {
name := strings.TrimSpace(row.ArtistName)
if name == "" {
continue
}
byArtist := make([]library.Album, 0, shelfSize)
for _, album := range albums {
if strings.EqualFold(album.ArtistName, name) {
byArtist = append(byArtist, album)
}
}
if len(byArtist) < artistShelfMin {
continue
}
if len(byArtist) > shelfSize {
byArtist = byArtist[:shelfSize]
}
return Shelf{
ID: "artist",
Kind: KindArtist,
Title: "More from " + name,
Subtitle: "One of your most played artists",
Albums: byArtist,
}, true
}
return Shelf{}, false
}
// artistPoolSize is how many top artists the favourite-artist shelf
// picks from.
const artistPoolSize = 5
// genreShelves picks a couple of genres the library actually has depth
// in, sampled from the top handful so the page varies between visits.
func (s *Service) genreShelves(
ctx context.Context,
byID map[int64]library.Album,
) []Shelf {
rows, err := s.db.ReadQueries.HomeTopGenres(ctx, genreCandidates)
if err != nil {
s.logger.Warn("home: top genres", "error", err)
return nil
}
rand.Shuffle(len(rows), func(i, j int) {
rows[i], rows[j] = rows[j], rows[i]
})
shelves := make([]Shelf, 0, maxGenreShelves)
for _, row := range rows {
if len(shelves) >= maxGenreShelves {
break
}
genre := strings.TrimSpace(row.Genre)
if genre == "" {
continue
}
ids, err := s.db.ReadQueries.HomeAlbumsByGenre(
ctx,
sqlcgen.HomeAlbumsByGenreParams{Name: genre, Limit: shelfSize},
)
if err != nil {
s.logger.Warn("home: albums by genre", "genre", genre, "error", err)
continue
}
found := resolve(ids, byID)
if len(found) == 0 {
continue
}
shelves = append(shelves, Shelf{
ID: "genre-" + genre,
Kind: KindGenre,
Title: genre,
Subtitle: "Because your library is full of it",
Albums: found,
})
}
return shelves
}
+259
View File
@@ -0,0 +1,259 @@
package home_test
import (
"log/slog"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/home"
"yellowjacket/backend/library"
)
// fakeLibrary answers the one question the home service asks of the
// library package, so these tests are about shelf selection rather than
// about album rendering.
type fakeLibrary struct {
albums []library.Album
err error
}
func (f fakeLibrary) GetAllAlbums() ([]library.Album, error) {
return f.albums, f.err
}
// seed inserts one album with one played-or-not track, returning the
// release group id. Written with raw SQL rather than the library
// scanner because the shelves are queries, and a query is best tested
// against rows it can be given precisely.
func seed(
t *testing.T,
db *database.DB,
name, artist, genre string,
playCount int,
lastPlayed string,
) int64 {
t.Helper()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.ExecContext(query, args...); err != nil {
t.Fatalf("seed %q: %v", query, err)
}
}
exec(`INSERT INTO artist_credit (text) VALUES (?)
ON CONFLICT DO NOTHING`, artist)
exec(`INSERT INTO release_groups (name, album_artist_credit_id)
VALUES (?, (SELECT id FROM artist_credit WHERE text = ?))`,
name, artist)
exec(`INSERT INTO recordings (name, artist_credit_id)
VALUES (?, (SELECT id FROM artist_credit WHERE text = ?))`,
name+" track", artist)
exec(`INSERT INTO release_group_recordings (release_group_id, recording_id)
VALUES ((SELECT MAX(id) FROM release_groups),
(SELECT MAX(id) FROM recordings))`)
exec(`INSERT INTO file_types (extension) VALUES ('mp3')
ON CONFLICT DO NOTHING`)
exec(`INSERT INTO audio_files
(file_path, length_milliseconds, file_type_id, recording_id,
play_count, last_played)
VALUES (?, 1000,
(SELECT MAX(id) FROM file_types),
(SELECT MAX(id) FROM recordings),
?, ?)`,
"/music/"+name+".mp3", playCount, nullable(lastPlayed))
if genre != "" {
exec(`INSERT INTO genres (name) VALUES (?) ON CONFLICT DO NOTHING`, genre)
exec(`INSERT INTO recording_genres (recording_id, genre_id)
VALUES ((SELECT MAX(id) FROM recordings),
(SELECT id FROM genres WHERE name = ?))`, genre)
}
var id int64
if err := db.QueryRowWriter(
`SELECT MAX(id) FROM release_groups`,
).Scan(&id); err != nil {
t.Fatalf("seed: read album id: %v", err)
}
return id
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func shelfKinds(shelves []home.Shelf) []home.Kind {
kinds := make([]home.Kind, 0, len(shelves))
for _, s := range shelves {
kinds = append(kinds, s.Kind)
}
return kinds
}
func hasKind(shelves []home.Shelf, kind home.Kind) bool {
for _, s := range shelves {
if s.Kind == kind {
return true
}
}
return false
}
func shelfFor(shelves []home.Shelf, kind home.Kind) home.Shelf {
for _, s := range shelves {
if s.Kind == kind {
return s
}
}
return home.Shelf{}
}
func TestGetShelvesEmptyLibraryHasNoShelves(t *testing.T) {
db := database.NewTestDB(t)
svc := home.NewService(slog.Default(), db, fakeLibrary{})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
if len(shelves) != 0 {
t.Fatalf("empty library produced shelves: %v", shelfKinds(shelves))
}
}
func TestGetShelvesOmitsShelvesWithNothingBehindThem(t *testing.T) {
// A library nothing has ever been played from must not claim to
// know what is on repeat: an empty row labelled with a reason is a
// worse answer than no row.
db := database.NewTestDB(t)
id := seed(t, db, "Quiet", "Nobody", "", 0, "")
svc := home.NewService(slog.Default(), db, fakeLibrary{
albums: []library.Album{{ID: id, Name: "Quiet", ArtistName: "Nobody"}},
})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
if hasKind(shelves, home.KindRecentlyPlayed) {
t.Error("recently-played shelf built from no plays")
}
if hasKind(shelves, home.KindMostPlayed) {
t.Error("most-played shelf built from no plays")
}
if !hasKind(shelves, home.KindUnplayed) {
t.Errorf("expected an unplayed shelf, got %v", shelfKinds(shelves))
}
if !hasKind(shelves, home.KindRecentlyAdded) {
t.Errorf("expected a recently-added shelf, got %v", shelfKinds(shelves))
}
}
func TestGetShelvesRanksPlayHistory(t *testing.T) {
db := database.NewTestDB(t)
old := seed(t, db, "Old Favourite", "A", "", 20, "2020-01-01 00:00:00")
recent := seed(t, db, "Last Night", "B", "", 3, "2999-01-01 00:00:00")
svc := home.NewService(slog.Default(), db, fakeLibrary{
albums: []library.Album{
{ID: old, Name: "Old Favourite", ArtistName: "A"},
{ID: recent, Name: "Last Night", ArtistName: "B"},
},
})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
played := shelfFor(shelves, home.KindRecentlyPlayed)
if len(played.Albums) == 0 || played.Albums[0].ID != recent {
t.Errorf("recently played led with %v, want the newest play", played.Albums)
}
most := shelfFor(shelves, home.KindMostPlayed)
if len(most.Albums) == 0 || most.Albums[0].ID != old {
t.Errorf("most played led with %v, want the highest play count", most.Albums)
}
// Played long ago is "forgotten"; the never-played fallback must
// not take over while there is real history to report.
forgotten := shelfFor(shelves, home.KindStale)
if len(forgotten.Albums) == 0 || forgotten.Albums[0].ID != old {
t.Errorf("forgotten shelf = %v, want the album last played in 2020", forgotten.Albums)
}
}
func TestGetShelvesBuildsAGenreShelf(t *testing.T) {
db := database.NewTestDB(t)
albums := make([]library.Album, 0, 4)
for _, name := range []string{"One", "Two", "Three", "Four"} {
id := seed(t, db, name, "Various", "Doom Jazz", 1, "2024-01-01 00:00:00")
albums = append(albums, library.Album{
ID: id, Name: name, ArtistName: "Various",
})
}
svc := home.NewService(slog.Default(), db, fakeLibrary{albums: albums})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
genre := shelfFor(shelves, home.KindGenre)
if genre.Title != "Doom Jazz" {
t.Fatalf("genre shelf = %q, want the library's one genre", genre.Title)
}
if len(genre.Albums) != len(albums) {
t.Errorf("genre shelf had %d albums, want %d", len(genre.Albums), len(albums))
}
}
func TestGetShelvesSkipsAlbumsTheLibraryNoLongerHas(t *testing.T) {
// The id queries and the album list are two reads, and a scan can
// remove an album between them. A stale id must vanish from the
// shelf, not render as a blank card.
db := database.NewTestDB(t)
kept := seed(t, db, "Kept", "A", "", 5, "2024-01-01 00:00:00")
seed(t, db, "Removed", "B", "", 5, "2024-01-02 00:00:00")
svc := home.NewService(slog.Default(), db, fakeLibrary{
albums: []library.Album{{ID: kept, Name: "Kept", ArtistName: "A"}},
})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
for _, shelf := range shelves {
for _, album := range shelf.Albums {
if album.ID != kept {
t.Fatalf("shelf %q surfaced a removed album: %+v", shelf.ID, album)
}
}
}
}