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
+119
View File
@@ -0,0 +1,119 @@
-- Queries behind the home page's "start listening" shelves.
--
-- Every one of these returns album ids and nothing else. The display
-- columns (cover art, artist credit, year) already have exactly one
-- correct expression of them, in GetAllAlbumsWithDetails, and a second
-- copy per shelf would be six more places for that to drift. The home
-- service joins the ids back to that one album list in Go.
-- name: HomeRecentlyPlayedAlbums :many
-- Albums with the most recent play, newest first.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC
LIMIT ?;
-- name: HomeRecentlyAddedAlbums :many
-- Newest albums. audio_files has no import timestamp, so the row id
-- stands in for one: it is monotonic and assigned at import, which is
-- the same ordering an added_at column would give.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY MAX(af.id) DESC
LIMIT ?;
-- name: HomeMostPlayedAlbums :many
-- Albums by total plays across their tracks.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC
LIMIT ?;
-- name: HomeUnplayedAlbums :many
-- Albums nothing on has ever been played, sampled at random so the
-- shelf is a different suggestion each time rather than the same
-- alphabetical head of the list forever.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) = 0
ORDER BY RANDOM()
LIMIT ?;
-- name: HomeStaleAlbums :many
-- Played before, but not for a long while.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?)
ORDER BY MAX(af.last_played) ASC
LIMIT ?;
-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?;
-- name: HomeAlbumsByGenre :many
-- A random sample of albums carrying a genre, so the same genre shelf
-- is not the same ten albums every time the page opens.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
JOIN genres g ON g.id = rgen.genre_id
WHERE g.name = ?
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?;
-- name: HomeTopGenres :many
-- Genres ranked by how much of the library carries them, restricted to
-- ones with at least a few albums: a shelf built from a genre one
-- album carries is a shelf about that one album.
SELECT
g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count
FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
GROUP BY g.id
HAVING album_count >= 3
ORDER BY album_count DESC
LIMIT ?;
-- name: HomeTopArtists :many
-- Artists by total plays, as the album-artist credit text the album
-- list already displays.
SELECT
COALESCE(ac.text, '') AS artist_name,
SUM(af.play_count) AS plays
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE ac.text <> ''
GROUP BY ac.text
HAVING plays > 0
ORDER BY plays DESC
LIMIT ?;
+367
View File
@@ -0,0 +1,367 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: home.sql
package sqlcgen
import (
"context"
"database/sql"
)
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
JOIN genres g ON g.id = rgen.genre_id
WHERE g.name = ?
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?
`
type HomeAlbumsByGenreParams struct {
Name string
Limit int64
}
// A random sample of albums carrying a genre, so the same genre shelf
// is not the same ten albums every time the page opens.
func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenreParams) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeAlbumsByGenre, arg.Name, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC
LIMIT ?
`
// Albums by total plays across their tracks.
func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeMostPlayedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?
`
func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeRandomAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY MAX(af.id) DESC
LIMIT ?
`
// Newest albums. audio_files has no import timestamp, so the row id
// stands in for one: it is monotonic and assigned at import, which is
// the same ordering an added_at column would give.
func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeRecentlyAddedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC
LIMIT ?
`
// Queries behind the home page's "start listening" shelves.
//
// Every one of these returns album ids and nothing else. The display
// columns (cover art, artist credit, year) already have exactly one
// correct expression of them, in GetAllAlbumsWithDetails, and a second
// copy per shelf would be six more places for that to drift. The home
// service joins the ids back to that one album list in Go.
// Albums with the most recent play, newest first.
func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeRecentlyPlayedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?)
ORDER BY MAX(af.last_played) ASC
LIMIT ?
`
type HomeStaleAlbumsParams struct {
Datetime interface{}
Limit int64
}
// Played before, but not for a long while.
func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeStaleAlbums, arg.Datetime, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeTopArtists = `-- name: HomeTopArtists :many
SELECT
COALESCE(ac.text, '') AS artist_name,
SUM(af.play_count) AS plays
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE ac.text <> ''
GROUP BY ac.text
HAVING plays > 0
ORDER BY plays DESC
LIMIT ?
`
type HomeTopArtistsRow struct {
ArtistName string
Plays sql.NullFloat64
}
// Artists by total plays, as the album-artist credit text the album
// list already displays.
func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArtistsRow, error) {
rows, err := q.db.QueryContext(ctx, homeTopArtists, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []HomeTopArtistsRow
for rows.Next() {
var i HomeTopArtistsRow
if err := rows.Scan(&i.ArtistName, &i.Plays); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeTopGenres = `-- name: HomeTopGenres :many
SELECT
g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count
FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
GROUP BY g.id
HAVING album_count >= 3
ORDER BY album_count DESC
LIMIT ?
`
type HomeTopGenresRow struct {
Genre string
AlbumCount int64
}
// Genres ranked by how much of the library carries them, restricted to
// ones with at least a few albums: a shelf built from a genre one
// album carries is a shelf about that one album.
func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenresRow, error) {
rows, err := q.db.QueryContext(ctx, homeTopGenres, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []HomeTopGenresRow
for rows.Next() {
var i HomeTopGenresRow
if err := rows.Scan(&i.Genre, &i.AlbumCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) = 0
ORDER BY RANDOM()
LIMIT ?
`
// Albums nothing on has ever been played, sampled at random so the
// shelf is a different suggestion each time rather than the same
// alphabetical head of the list forever.
func (q *Queries) HomeUnplayedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeUnplayedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}