lyrics

lyrics
git clone git://brookjeynes.dev/bjeynes/lyrics.git
Log | Files | Refs

commit 38f4cc890fb8c3359cbf28632acf6ef178c3760e
parent 9f405067acfb0a1f3020b474ac4cbb5c5d741c3b
Author: brookjeynes <me@brookjeynes.dev>
Date:   Sat, 13 Jun 2026 12:59:46 +1000

feat: add recently added songs on homepage

Signed-off-by: brookjeynes <me@brookjeynes.dev>

Diffstat:
Minternal/db/song.go | 309+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Minternal/server/index.go | 10+++++++++-
Minternal/server/ui/views/index/index.go | 3+++
Minternal/server/ui/views/index/index.templ | 48++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 252 insertions(+), 118 deletions(-)

diff --git a/internal/db/song.go b/internal/db/song.go @@ -25,32 +25,40 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) { return domain.Song{}, fmt.Errorf("failed to parse release_date: %w", err) } - // fetch song_artists rows - type songArtistRow struct { - artistID domain.ArtistID - role domain.ArtistRole - sortOrder int - } - var artistRows []songArtistRow - { rows, err := e.Query(` - select artist_id, role, sort_order - from song_artists - where song_id = ? - order by sort_order + select + a.id, + a.name, + a.latin_name, + a.disambiguation, + a.bio, + sa.role, + sa.sort_order + from song_artists sa + join artists a on a.id = sa.artist_id + where sa.song_id = ? + order by sa.sort_order `, id) if err != nil { return domain.Song{}, fmt.Errorf("failed to query song artists: %w", err) } for rows.Next() { - var r songArtistRow - if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil { + var artist domain.SongArtist + if err := rows.Scan( + &artist.Artist.ID, + &artist.Artist.Name, + &artist.Artist.LatinName, + &artist.Artist.Disambiguation, + &artist.Artist.Bio, + &artist.Role, + &artist.SortOrder, + ); err != nil { _ = rows.Close() return domain.Song{}, fmt.Errorf("failed to scan song artist: %w", err) } - artistRows = append(artistRows, r) + song.Artists = append(song.Artists, artist) } if err := rows.Err(); err != nil { _ = rows.Close() @@ -59,158 +67,229 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) { _ = rows.Close() } - // fetch full artist records in one query - if len(artistRows) > 0 { - artistIDs := make([]any, len(artistRows)) - for i, r := range artistRows { - artistIDs[i] = r.artistID - } - placeholders := strings.Repeat("?,", len(artistIDs)) - placeholders = placeholders[:len(placeholders)-1] - - artistMap := make(map[domain.ArtistID]domain.Artist, len(artistIDs)) + { rows, err := e.Query(` - select id, name, latin_name, disambiguation, bio - from artists - where id in (`+placeholders+`) - `, artistIDs...) + select + a.id, + a.title, + a.latin_title, + a.release_date, + sa.track_number + from song_albums sa + join albums a on a.id = sa.album_id + where sa.song_id = ? + order by sa.track_number + `, id) if err != nil { - return domain.Song{}, fmt.Errorf("failed to query artists: %w", err) + return domain.Song{}, fmt.Errorf("failed to query song albums: %w", err) } for rows.Next() { - var a domain.Artist - if err := rows.Scan(&a.ID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil { + var album domain.SongAlbum + var albumReleaseDateStr string + if err := rows.Scan( + &album.Album.ID, + &album.Album.Title, + &album.Album.LatinTitle, + &albumReleaseDateStr, + &album.TrackNumber, + ); err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to scan artist: %w", err) + return domain.Song{}, fmt.Errorf("failed to scan song album: %w", err) } - artistMap[a.ID] = a + + album.Album.ReleaseDate, err = time.Parse(time.RFC3339, albumReleaseDateStr) + if err != nil { + _ = rows.Close() + return domain.Song{}, fmt.Errorf("failed to parse album release_date: %w", err) + } + + song.Albums = append(song.Albums, album) } if err := rows.Err(); err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to iterate artists: %w", err) + return domain.Song{}, fmt.Errorf("failed to iterate song albums: %w", err) } _ = rows.Close() - - song.Artists = make([]domain.SongArtist, len(artistRows)) - for i, r := range artistRows { - song.Artists[i] = domain.SongArtist{ - Artist: artistMap[r.artistID], - Role: r.role, - SortOrder: r.sortOrder, - } - } } - // fetch song_albums rows - type songAlbumRow struct { - albumID domain.AlbumID - trackNumber *int - } - var albumRows []songAlbumRow - + // fetch genres { rows, err := e.Query(` - select album_id, track_number - from song_albums + select genre + from song_genres where song_id = ? `, id) if err != nil { - return domain.Song{}, fmt.Errorf("failed to query song albums: %w", err) + return domain.Song{}, fmt.Errorf("failed to query song genres: %w", err) } for rows.Next() { - var r songAlbumRow - if err := rows.Scan(&r.albumID, &r.trackNumber); err != nil { + var g domain.Genre + if err := rows.Scan(&g); err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to scan song album: %w", err) + return domain.Song{}, fmt.Errorf("failed to scan song genre: %w", err) } - albumRows = append(albumRows, r) + song.Genres = append(song.Genres, g) } if err := rows.Err(); err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to iterate song albums: %w", err) + return domain.Song{}, fmt.Errorf("failed to iterate song genres: %w", err) } _ = rows.Close() } - // fetch full album records in one query - if len(albumRows) > 0 { - albumIDs := make([]any, len(albumRows)) - for i, r := range albumRows { - albumIDs[i] = r.albumID + return song, nil +} + +func RecentSongs(e Execer, limit int) ([]domain.Song, error) { + rows, err := e.Query(` + select id, title, latin_title, lyrics, release_date + from songs + order by created_at desc + limit ? + `, limit) + if err != nil { + return nil, fmt.Errorf("failed to query recent songs: %w", err) + } + + var songs []domain.Song + for rows.Next() { + var song domain.Song + var releaseDateStr string + + if err := rows.Scan(&song.ID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("failed to scan recent song: %w", err) } - placeholders := strings.Repeat("?,", len(albumIDs)) - placeholders = placeholders[:len(placeholders)-1] - - albumMap := make(map[domain.AlbumID]domain.Album, len(albumIDs)) - { - rows, err := e.Query(` - select id, title, latin_title, release_date - from albums - where id in (`+placeholders+`) - `, albumIDs...) - if err != nil { - return domain.Song{}, fmt.Errorf("failed to query albums: %w", err) - } - for rows.Next() { - var a domain.Album - var relDate string - if err := rows.Scan(&a.ID, &a.Title, &a.LatinTitle, &relDate); err != nil { - _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to scan album: %w", err) - } - a.ReleaseDate, err = time.Parse(time.RFC3339, relDate) - if err != nil { - _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to parse album release_date: %w", err) - } - albumMap[a.ID] = a - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to iterate albums: %w", err) - } + song.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr) + if err != nil { _ = rows.Close() + return nil, fmt.Errorf("failed to parse recent song release_date: %w", err) } - song.Albums = make([]domain.SongAlbum, len(albumRows)) - for i, r := range albumRows { - song.Albums[i] = domain.SongAlbum{ - Album: albumMap[r.albumID], - TrackNumber: r.trackNumber, + songs = append(songs, song) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("failed to iterate recent songs: %w", err) + } + _ = rows.Close() + + if len(songs) == 0 { + return nil, nil + } + + songIDs := make([]any, len(songs)) + songByID := make(map[domain.SongID]*domain.Song, len(songs)) + for i := range songs { + songIDs[i] = songs[i].ID + songByID[songs[i].ID] = &songs[i] + } + placeholders := strings.Repeat("?,", len(songIDs)) + placeholders = placeholders[:len(placeholders)-1] + + { + rows, err := e.Query(` + select + sa.song_id, + a.id, + a.name, + a.latin_name, + a.disambiguation, + a.bio, + sa.role, + sa.sort_order + from song_artists sa + join artists a on a.id = sa.artist_id + where sa.song_id in (`+placeholders+`) + order by sa.song_id, sa.sort_order + `, songIDs...) + if err != nil { + return nil, fmt.Errorf("failed to query recent song artists: %w", err) + } + + for rows.Next() { + var songID domain.SongID + var artist domain.SongArtist + if err := rows.Scan( + &songID, + &artist.Artist.ID, + &artist.Artist.Name, + &artist.Artist.LatinName, + &artist.Artist.Disambiguation, + &artist.Artist.Bio, + &artist.Role, + &artist.SortOrder, + ); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("failed to scan recent song artist: %w", err) + } + + if song := songByID[songID]; song != nil { + song.Artists = append(song.Artists, artist) } } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("failed to iterate recent song artists: %w", err) + } + _ = rows.Close() } - // fetch genres { rows, err := e.Query(` - select genre - from song_genres - where song_id = ? - `, id) + select + sa.song_id, + a.id, + a.title, + a.latin_title, + a.release_date, + sa.track_number + from song_albums sa + join albums a on a.id = sa.album_id + where sa.song_id in (`+placeholders+`) + order by sa.song_id, sa.track_number + `, songIDs...) if err != nil { - return domain.Song{}, fmt.Errorf("failed to query song genres: %w", err) + return nil, fmt.Errorf("failed to query recent song albums: %w", err) } - defer func() { - _ = rows.Close() - }() for rows.Next() { - var g domain.Genre - if err := rows.Scan(&g); err != nil { - return domain.Song{}, fmt.Errorf("failed to scan song genre: %w", err) + var songID domain.SongID + var album domain.SongAlbum + var albumReleaseDateStr string + if err := rows.Scan( + &songID, + &album.Album.ID, + &album.Album.Title, + &album.Album.LatinTitle, + &albumReleaseDateStr, + &album.TrackNumber, + ); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("failed to scan recent song album: %w", err) + } + + album.Album.ReleaseDate, err = time.Parse(time.RFC3339, albumReleaseDateStr) + if err != nil { + _ = rows.Close() + return nil, fmt.Errorf("failed to parse recent album release_date: %w", err) + } + + if song := songByID[songID]; song != nil { + song.Albums = append(song.Albums, album) } - song.Genres = append(song.Genres, g) } if err := rows.Err(); err != nil { - return domain.Song{}, fmt.Errorf("failed to iterate song genres: %w", err) + _ = rows.Close() + return nil, fmt.Errorf("failed to iterate recent song albums: %w", err) } + _ = rows.Close() } - return song, nil + return songs, nil } func InsertSong(e Execer, song domain.NewSong) error { diff --git a/internal/server/index.go b/internal/server/index.go @@ -3,6 +3,7 @@ package server import ( "context" "errors" + "lyrics/internal/db" "lyrics/internal/server/ui/views/index" "net/http" ) @@ -10,7 +11,14 @@ import ( func (ss *ServerState) Index(w http.ResponseWriter, r *http.Request) { logger := ss.logger.With("handler", "Index") - err := index.Index(index.IndexParams{}).Render(r.Context(), w) + songs, err := db.RecentSongs(ss.db, 10) + if err != nil { + logger.Error("failed to get recent songs", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + err = index.Index(index.IndexParams{Songs: songs}).Render(r.Context(), w) if err != nil { if errors.Is(err, context.Canceled) { return diff --git a/internal/server/ui/views/index/index.go b/internal/server/ui/views/index/index.go @@ -1,4 +1,7 @@ package index +import "lyrics/internal/domain" + type IndexParams struct { + Songs []domain.Song } diff --git a/internal/server/ui/views/index/index.templ b/internal/server/ui/views/index/index.templ @@ -1,9 +1,53 @@ package index -import "lyrics/internal/server/ui/layouts/base" +import ( + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/layouts/base" +) templ Index(params IndexParams) { @base.Base(base.BaseParams{Title: "home"}) { - <span>hi</span> + <section> + <h1>Recently Added Songs</h1> + if len(params.Songs) == 0 { + <p>No songs yet.</p> + } else { + <ol> + for _, song := range params.Songs { + <li> + <a href={ routes.Song(song) }> + { song.Title } + if song.LatinTitle != nil { + <span>({ *song.LatinTitle })</span> + } + </a> + if len(song.Artists) > 0 { + <span>by </span> + for i, artist := range song.Artists { + if i > 0 { + <span>, </span> + } + <a href={ routes.Artist(artist.Artist) }> + { artist.Artist.Name } + if artist.Artist.LatinName != nil { + <span>({ *artist.Artist.LatinName })</span> + } + </a> + } + } + if len(song.Albums) > 0 { + <span>on </span> + <a href={ routes.Album(song.Albums[0].Album) }> + { song.Albums[0].Album.Title } + if song.Albums[0].Album.LatinTitle != nil { + <span>({ *song.Albums[0].Album.LatinTitle })</span> + } + </a> + } + </li> + } + </ol> + } + </section> } }