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:
@@ -23,6 +23,7 @@ import (
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/explore"
|
||||
"yellowjacket/backend/frontendutil"
|
||||
"yellowjacket/backend/home"
|
||||
"yellowjacket/backend/jobs"
|
||||
"yellowjacket/backend/library"
|
||||
"yellowjacket/backend/maintenance"
|
||||
@@ -210,6 +211,11 @@ func NewYellowJacketApp(
|
||||
yjApp.explore,
|
||||
yjApp.autotag,
|
||||
jobs.NewService(yjApp.jobs),
|
||||
home.NewService(
|
||||
yjApp.logger.WithGroup("home"),
|
||||
yjApp.database,
|
||||
yjApp.library,
|
||||
),
|
||||
}
|
||||
|
||||
if yjApp.downloadSvc != nil {
|
||||
|
||||
@@ -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 ?;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user