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