Files
yellowjacket/backend/home/home.go
T
logan ff687f0bd9 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.
2026-08-11 01:15:34 -04:00

423 lines
11 KiB
Go

// 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
}