diff --git a/backend/app.go b/backend/app.go index ae2a597..f856725 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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 { diff --git a/backend/database/sql/queries/home.sql b/backend/database/sql/queries/home.sql new file mode 100644 index 0000000..f033510 --- /dev/null +++ b/backend/database/sql/queries/home.sql @@ -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 ?; diff --git a/backend/database/sql/sqlcgen/home.sql.go b/backend/database/sql/sqlcgen/home.sql.go new file mode 100644 index 0000000..75e8385 --- /dev/null +++ b/backend/database/sql/sqlcgen/home.sql.go @@ -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 +} diff --git a/backend/home/home.go b/backend/home/home.go new file mode 100644 index 0000000..915ae62 --- /dev/null +++ b/backend/home/home.go @@ -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 +} diff --git a/backend/home/home_test.go b/backend/home/home_test.go new file mode 100644 index 0000000..db1c564 --- /dev/null +++ b/backend/home/home_test.go @@ -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) + } + } + } +} diff --git a/e2e/specs/home.spec.ts b/e2e/specs/home.spec.ts new file mode 100644 index 0000000..8547289 --- /dev/null +++ b/e2e/specs/home.spec.ts @@ -0,0 +1,81 @@ +import { test, expect, waitForEvent, resetEvents } from '../support/fixtures.js'; + +/** + * The home page, which is the one view whose content is a *judgement* + * rather than a listing: `backend/home` decides which shelves exist for + * this library and why, and the page is only correct if that reasoning + * survives to the screen. + * + * The fixture library has never been played, so the shelves that need + * history are legitimately absent — asserting on which ones appear is + * asserting that the page does not invent them. + */ +test.describe('home', () => { + test.beforeEach(async ({ app }) => { + await app.getByTestId('nav-home').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'home', + ); + }); + + test('offers shelves, each with the reason it is there', async ({ app }) => { + const shelves = app.locator('home-view .shelf'); + + await expect(shelves.first()).toBeVisible(); + + const count = await shelves.count(); + expect(count).toBeGreaterThan(0); + + // Every shelf says why it exists. A row of covers with no reason + // is the albums grid, which the user already has. + for (let i = 0; i < count; i += 1) { + await expect(shelves.nth(i).locator('.shelf-sub')).not.toBeEmpty(); + } + }); + + test('never renders a shelf with nothing on it', async ({ app }) => { + // The reason shelves are built server-side: a row that promises + // "what you play the most" and then shows nothing is worse than no + // row, so a shelf with no albums must not reach the page at all. + const shelves = app.locator('home-view .shelf'); + + await expect(shelves.first()).toBeVisible(); + + const count = await shelves.count(); + + for (let i = 0; i < count; i += 1) { + await expect(shelves.nth(i).locator('.card').first()).toBeVisible(); + } + }); + + test('a cover opens that album', async ({ app }) => { + const card = app.locator('home-view .card').first(); + const name = await card.locator('.name').innerText(); + + await card.click(); + + await expect(app.locator('explore-album-details')).toBeVisible(); + await expect(app.locator('explore-album-details .album-title')).toContainText( + name, + ); + }); + + test('the play button plays the album instead of opening it', async ({ + app, + }) => { + await resetEvents(app); + + const card = app.locator('home-view .card').first(); + + await card.hover(); + await card.locator('.play').click(); + + await waitForEvent(app, 'TrackChanged'); + + // Playing must not also navigate: the two actions live on the same + // card and the inner one has to win outright. + await expect(app.locator('home-view')).toBeVisible(); + await expect(app.locator('explore-album-details')).toHaveCount(0); + }); +}); diff --git a/frontend/index.ts b/frontend/index.ts index dffefaf..74b7e15 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -24,6 +24,7 @@ import '@components/first-run-wizard/first-run-wizard.ts'; import '@components/jobs/job-indicator.ts'; import '@components/jobs/jobs-view.ts'; import '@components/downloads-view/downloads-view.ts'; +import '@components/home-view/home-view.ts'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; @@ -57,6 +58,7 @@ setBasePath('/dist/webawesome'); // --------------------------------------------------------------------------- const VIEW_TAGS: Record = { + home: 'home-view', tracks: 'track-list', albums: 'cover-grid', artists: 'artists-view', diff --git a/frontend/src/components/home-view/home-view.ts b/frontend/src/components/home-view/home-view.ts new file mode 100644 index 0000000..d18749f --- /dev/null +++ b/frontend/src/components/home-view/home-view.ts @@ -0,0 +1,376 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '@awesome.me/webawesome/dist/components/button/button.js'; +import { GetShelves } from '@go/home/Service'; +import { GetAlbumTracks } from '@go/library/Library'; +import type { home, library } from '@go/models'; +import { queueStore } from '@store/queue-store'; +import { libraryStore } from '@store/library-store'; +import { EventsOn } from '@runtime/runtime'; +import { Events } from '../../events'; +import { designTokens } from '../../styles/tokens.css'; + +type Shelf = home.Shelf; + +/** Icon per shelf kind — a row's reason, at a glance. */ +const KIND_ICONS: Record = { + 'recently-played': 'clock-rotate-left', + 'recently-added': 'star', + 'most-played': 'repeat', + unplayed: 'box-open', + stale: 'hourglass-half', + artist: 'user', + genre: 'masks-theater', + random: 'shuffle', +}; + +/** + * The home page: a set of ways *into* the library, rather than another + * view of it. + * + * Everything here is computed by `backend/home`, including the reason + * each row exists, so the rows can change with the user's listening + * without the frontend holding a second opinion about what "on repeat" + * means. This component's job is only to render them and to make a + * cover do the two things a cover should: open the album, or play it. + */ +@customElement('home-view') +export class HomeView extends LitElement { + @state() private shelves: Shelf[] = []; + + @state() private loading = true; + + @state() private failed = false; + + /** Generation of the library the shelves were built from. */ + private builtFromGeneration = -1; + + private unsubScan?: () => void; + + static override styles = [ + designTokens, + css` + :host { + display: block; + height: 100%; + overflow-y: auto; + padding: 24px 20px 40px; + box-sizing: border-box; + } + + header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 4px; + } + + h1 { + margin: 0; + font-size: 24px; + font-weight: 700; + color: var(--yj-text-primary, #fff); + flex: 1; + } + + .lede { + margin: 0 0 24px; + font-size: var(--yj-text-md, 13px); + color: var(--yj-text-secondary, #b3b3b3); + } + + .shelf { + margin-bottom: 28px; + } + + .shelf-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 2px; + } + + .shelf-title { + font-size: var(--yj-text-xl, 18px); + font-weight: 700; + color: var(--yj-text-primary, #fff); + } + + .shelf-sub { + margin: 0 0 10px; + font-size: var(--yj-text-sm, 12px); + color: var(--yj-text-tertiary, #888); + } + + .row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 160px; + gap: 14px; + overflow-x: auto; + padding-bottom: 6px; + scrollbar-width: thin; + } + + .card { + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + color: inherit; + display: block; + } + + .art { + position: relative; + width: 160px; + height: 160px; + border-radius: 6px; + overflow: hidden; + background: var(--yj-bg-surface, #181818); + display: flex; + align-items: center; + justify-content: center; + color: var(--yj-text-tertiary, #888); + } + + .art img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .play { + position: absolute; + right: 8px; + bottom: 8px; + width: 38px; + height: 38px; + border: none; + border-radius: 50%; + background: var(--yj-accent, #ffd43b); + color: #000; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transform: translateY(6px); + transition: opacity 0.12s ease, transform 0.12s ease; + } + + .card:hover .play, + .card:focus-within .play { + opacity: 1; + transform: translateY(0); + } + + .name { + margin-top: 8px; + font-size: var(--yj-text-md, 13px); + color: var(--yj-text-primary, #fff); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .artist { + font-size: var(--yj-text-sm, 12px); + color: var(--yj-text-tertiary, #888); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .empty { + padding: 48px 20px; + text-align: center; + color: var(--yj-text-tertiary, #888); + font-size: var(--yj-text-lg, 15px); + } + `, + ]; + + override connectedCallback(): void { + super.connectedCallback(); + void this.load(); + + // A finished scan changes what every shelf would say, and the + // home page is the view most likely to be sitting open while + // one runs. + this.unsubScan = EventsOn(Events.LibraryScanComplete, () => { + void this.load(); + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.unsubScan?.(); + this.unsubScan = undefined; + } + + /** + * Rebuild when the view is shown again after the library changed. + * Navigation keeps this element alive (see `frontend/index.ts`), so + * without this the shelves would be as old as the session. + */ + override willUpdate(): void { + if ( + !this.loading + && this.builtFromGeneration !== libraryStore.changeGeneration + ) { + void this.load(); + } + } + + private async load(): Promise { + this.builtFromGeneration = libraryStore.changeGeneration; + this.loading = true; + + try { + this.shelves = (await GetShelves()) ?? []; + this.failed = false; + } catch (err) { + console.error('Could not build the home page:', err); + this.failed = true; + } finally { + this.loading = false; + } + } + + override render() { + return html` +
+

Home

+ void this.load()} + > + + Shuffle + +
+

Somewhere to start listening.

+ ${this.renderBody()} + `; + } + + private renderBody() { + if (this.loading && this.shelves.length === 0) { + return html`
Looking through your library\u2026
`; + } + + if (this.failed) { + return html`
+ Could not read your library just now. +
`; + } + + if (this.shelves.length === 0) { + return html`
+ Nothing to suggest yet \u2014 add a music folder under Settings + and the shelves fill in once it has been scanned. +
`; + } + + return this.shelves.map((shelf) => this.renderShelf(shelf)); + } + + private renderShelf(shelf: Shelf) { + return html` +
+
+ + ${shelf.title} +
+

${shelf.subtitle}

+
+ ${shelf.albums.map((album) => this.renderCard(album))} +
+
+ `; + } + + private renderCard(album: library.Album) { + const art = album.CoverArtMedium || album.CoverArtSmall || album.CoverArtPath; + + return html` +
this.openAlbum(album)} + @keydown=${(e: KeyboardEvent) => this.onCardKey(e, album)} + > +
+ ${art + ? html`` + : html``} + +
+
${album.Name}
+ ${album.ArtistName + ? html`
${album.ArtistName}
` + : nothing} +
+ `; + } + + private onCardKey(e: KeyboardEvent, album: library.Album): void { + if (e.key !== 'Enter' && e.key !== ' ') return; + + e.preventDefault(); + this.openAlbum(album); + } + + private openAlbum(album: library.Album): void { + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { + view: 'explore-album-details', + releaseGroupMBID: album.MBID || '', + albumName: album.Name, + artistName: album.ArtistName, + localAlbumId: album.ID, + }, + }), + ); + } + + private async playAlbum(album: library.Album): Promise { + try { + const tracks = await GetAlbumTracks(album.ID); + const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean); + + if (paths.length === 0) return; + + queueStore.setQueue(paths, 0, true); + } catch (err) { + console.error('Could not play that album:', err); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'home-view': HomeView; + } +} diff --git a/frontend/test/components/home-view.test.ts b/frontend/test/components/home-view.test.ts new file mode 100644 index 0000000..7ea962a --- /dev/null +++ b/frontend/test/components/home-view.test.ts @@ -0,0 +1,151 @@ +/** + * The home page renders whatever `backend/home` decided, and nothing + * else: the shelves, their stated reasons, and two things a cover can + * do. So these tests are about the contract between the two — that a + * shelf's reason is displayed rather than swallowed, that a card opens + * the album it names, and that a play button plays it instead of + * opening it. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/home-view/home-view'; +import { stub, calls, lastArgs, stubFailure } from '@test/support/harness'; +import { fixture, shadow, shadowAll, texts, text } from '@test/support/render'; + +function album(id: number, name: string, artist = 'Artist') { + return { + ID: id, + Name: name, + ArtistName: artist, + MBID: '', + Year: 2000, + ReleaseYear: 2000, + CoverArtPath: '', + CoverArtSmall: '', + CoverArtMedium: '', + CoverArtLarge: '', + ArtistMBID: '', + }; +} + +const SHELVES = [ + { + id: 'recently-played', + kind: 'recently-played', + title: 'Pick up where you left off', + subtitle: 'The last albums you played', + albums: [album(1, 'Kid A'), album(2, 'Amnesiac')], + }, + { + id: 'genre-Doom Jazz', + kind: 'genre', + title: 'Doom Jazz', + subtitle: 'Because your library is full of it', + albums: [album(3, 'Black Ships')], + }, +]; + +describe('home view', () => { + beforeEach(() => { + stub('home.Service.GetShelves', SHELVES); + stub('library.Library.GetAlbumTracks', [ + { FilePath: '/music/1.mp3' }, + { FilePath: '/music/2.mp3' }, + ]); + }); + + it('renders a row per shelf, each with the reason it exists', async () => { + const el = await fixture('home-view'); + await el.updateComplete; + + expect(texts(el, '.shelf-title')).toEqual([ + 'Pick up where you left off', + 'Doom Jazz', + ]); + + // The subtitle is the whole difference between a shelf and a grid. + expect(texts(el, '.shelf-sub')).toEqual([ + 'The last albums you played', + 'Because your library is full of it', + ]); + }); + + it('keys each row by the kind the backend assigned', async () => { + const el = await fixture('home-view'); + await el.updateComplete; + + expect( + shadowAll(el, '.shelf').map((s) => s.getAttribute('data-kind')), + ).toEqual(['recently-played', 'genre']); + }); + + it('opens the album a card names, by local id', async () => { + const el = await fixture('home-view'); + await el.updateComplete; + + const seen: unknown[] = []; + el.addEventListener('navigate', (e) => seen.push((e as CustomEvent).detail)); + + shadow(el, '.card')!.click(); + + expect(seen).toEqual([ + { + view: 'explore-album-details', + releaseGroupMBID: '', + albumName: 'Kid A', + artistName: 'Artist', + localAlbumId: 1, + }, + ]); + }); + + it('plays the album from the play button without navigating', async () => { + const el = await fixture('home-view'); + await el.updateComplete; + + const seen: unknown[] = []; + el.addEventListener('navigate', (e) => seen.push(e)); + + shadow(el, '.play')!.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(seen).toEqual([]); + expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]); + expect(lastArgs('queue.Queue.SetQueue')).toEqual([ + ['/music/1.mp3', '/music/2.mp3'], + 0, + true, + ]); + }); + + it('says so rather than rendering an empty page when there is nothing', async () => { + stub('home.Service.GetShelves', []); + + const el = await fixture('home-view'); + await el.updateComplete; + + expect(text(el, '.empty')).toContain('Nothing to suggest yet'); + }); + + it('reports a backend failure instead of pretending the library is empty', async () => { + stubFailure('home.Service.GetShelves'); + + const el = await fixture('home-view'); + await el.updateComplete; + await el.updateComplete; + + expect(text(el, '.empty')).toContain('Could not read your library'); + }); + + it('rebuilds on demand', async () => { + const el = await fixture('home-view'); + await el.updateComplete; + + const before = calls('home.Service.GetShelves').length; + + shadow(el, 'wa-button')!.click(); + await el.updateComplete; + + expect(calls('home.Service.GetShelves').length).toBe(before + 1); + }); +}); diff --git a/frontend/wailsjs/go/home/Service.d.ts b/frontend/wailsjs/go/home/Service.d.ts new file mode 100755 index 0000000..43c908e --- /dev/null +++ b/frontend/wailsjs/go/home/Service.d.ts @@ -0,0 +1,5 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {home} from '../models'; + +export function GetShelves():Promise>; diff --git a/frontend/wailsjs/go/home/Service.js b/frontend/wailsjs/go/home/Service.js new file mode 100755 index 0000000..0c41620 --- /dev/null +++ b/frontend/wailsjs/go/home/Service.js @@ -0,0 +1,7 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function GetShelves() { + return window['go']['home']['Service']['GetShelves'](); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 163dd59..e67bc19 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1381,6 +1381,49 @@ export namespace explore { } +export namespace home { + + export class Shelf { + id: string; + kind: string; + title: string; + subtitle: string; + albums: library.Album[]; + + static createFrom(source: any = {}) { + return new Shelf(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.kind = source["kind"]; + this.title = source["title"]; + this.subtitle = source["subtitle"]; + this.albums = this.convertValues(source["albums"], library.Album); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace jobs { export class Caps {