commit 0ca68e5b20e1d1b08ee1780a86bf94f3001186a6
parent cf34dd56f31ef6579561b61e2c06f8c1ad685544
Author: brookjeynes <me@brookjeynes.dev>
Date: Tue, 12 May 2026 18:12:27 +1000
feat: create artist page
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
8 files changed, 441 insertions(+), 9 deletions(-)
diff --git a/internal/db/album.go b/internal/db/album.go
@@ -7,7 +7,137 @@ import (
"time"
)
-func InsertAlbum(e Execer, album domain.Album) error {
+func GetAlbum(e Execer, id domain.AlbumID) (domain.Album, error) {
+ var album domain.Album
+ var releaseDateStr string
+
+ err := e.QueryRow(`
+ select id, title, latin_name, release_date
+ from albums
+ where id = ?
+ `, id).Scan(&album.ID, &album.Title, &album.LatinName, &releaseDateStr)
+ if err != nil {
+ return domain.Album{}, fmt.Errorf("failed to get album: %w", err)
+ }
+
+ album.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr)
+ if err != nil {
+ return domain.Album{}, fmt.Errorf("failed to parse album release_date: %w", err)
+ }
+
+ // fetch album_artists rows
+ type albumArtistRow struct {
+ artistID domain.ArtistID
+ role domain.ArtistRole
+ sortOrder int
+ }
+ var artistRows []albumArtistRow
+
+ rows, err := e.Query(`
+ select artist_id, role, sort_order
+ from album_artists
+ where album_id = ?
+ order by sort_order
+ `, id)
+ if err != nil {
+ return domain.Album{}, fmt.Errorf("failed to query album artists: %w", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var r albumArtistRow
+ if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil {
+ return domain.Album{}, fmt.Errorf("failed to scan album artist: %w", err)
+ }
+ artistRows = append(artistRows, r)
+ }
+ if err := rows.Err(); err != nil {
+ return domain.Album{}, fmt.Errorf("failed to iterate album artists: %w", err)
+ }
+ 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))
+ aRows, err := e.Query(`
+ select id, name, latin_name, disambiguation, bio
+ from artists
+ where id in (`+placeholders+`)
+ `, artistIDs...)
+ if err != nil {
+ return domain.Album{}, fmt.Errorf("failed to query artists: %w", err)
+ }
+ defer aRows.Close()
+ for aRows.Next() {
+ var a domain.Artist
+ if err := aRows.Scan(&a.ID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil {
+ return domain.Album{}, fmt.Errorf("failed to scan artist: %w", err)
+ }
+ artistMap[a.ID] = a
+ }
+ if err := aRows.Err(); err != nil {
+ return domain.Album{}, fmt.Errorf("failed to iterate artists: %w", err)
+ }
+ aRows.Close()
+
+ album.Artists = make([]domain.AlbumArtist, len(artistRows))
+ for i, r := range artistRows {
+ album.Artists[i] = domain.AlbumArtist{
+ Artist: artistMap[r.artistID],
+ Role: r.role,
+ SortOrder: r.sortOrder,
+ }
+ }
+ }
+
+ // fetch songs on this album ordered by track_number
+ type albumSongRow struct {
+ songID domain.SongID
+ trackNumber *int
+ }
+ var songRows []albumSongRow
+
+ sRows, err := e.Query(`
+ select song_id, track_number
+ from song_albums
+ where album_id = ?
+ order by track_number
+ `, id)
+ if err != nil {
+ return domain.Album{}, fmt.Errorf("failed to query album songs: %w", err)
+ }
+ defer sRows.Close()
+ for sRows.Next() {
+ var r albumSongRow
+ if err := sRows.Scan(&r.songID, &r.trackNumber); err != nil {
+ return domain.Album{}, fmt.Errorf("failed to scan album song: %w", err)
+ }
+ songRows = append(songRows, r)
+ }
+ if err := sRows.Err(); err != nil {
+ return domain.Album{}, fmt.Errorf("failed to iterate album songs: %w", err)
+ }
+ sRows.Close()
+
+ album.Songs = make([]domain.Song, 0, len(songRows))
+ for _, r := range songRows {
+ song, err := GetSong(e, r.songID)
+ if err != nil {
+ return domain.Album{}, fmt.Errorf("failed to get song %d: %w", r.songID, err)
+ }
+ album.Songs = append(album.Songs, song)
+ }
+
+ return album, nil
+}
+
+func InsertAlbum(e Execer, album domain.AlbumRecord) error {
result, err := e.Exec(`
insert into albums (
title,
@@ -81,7 +211,6 @@ func SearchAlbum(e Execer, albumName string) ([]domain.Album, error) {
}
album.ReleaseDate = releaseDate
-
results = append(results, album)
}
diff --git a/internal/db/artist.go b/internal/db/artist.go
@@ -7,6 +7,126 @@ import (
"time"
)
+func GetArtist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []domain.Song, []domain.Song, error) {
+ var artist domain.Artist
+
+ err := e.QueryRow(`
+ select id, name, latin_name, disambiguation, bio
+ from artists
+ where id = ?
+ `, id).Scan(&artist.ID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get artist: %w", err)
+ }
+
+ // fetch albums where artist is primary, ordered by release_date
+ albumRows, err := e.Query(`
+ select album_id
+ from album_artists
+ where artist_id = ?
+ and role = ?
+ order by (select release_date from albums where id = album_id)
+ `, id, domain.RolePrimary)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to query artist albums: %w", err)
+ }
+ defer albumRows.Close()
+ var albumIDs []domain.AlbumID
+ for albumRows.Next() {
+ var albumID domain.AlbumID
+ if err := albumRows.Scan(&albumID); err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to scan album id: %w", err)
+ }
+ albumIDs = append(albumIDs, albumID)
+ }
+ if err := albumRows.Err(); err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to iterate artist albums: %w", err)
+ }
+ albumRows.Close()
+
+ albums := make([]domain.Album, 0, len(albumIDs))
+ for _, albumID := range albumIDs {
+ album, err := GetAlbum(e, albumID)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get album %d: %w", albumID, err)
+ }
+ albums = append(albums, album)
+ }
+
+ // fetch primary singles (primary role, no album) ordered by release_date
+ singlesRows, err := e.Query(`
+ select sa.song_id
+ from song_artists sa
+ join songs s on s.id = sa.song_id
+ where sa.artist_id = ?
+ and sa.role = ?
+ and not exists (select 1 from song_albums where song_id = sa.song_id)
+ order by s.release_date
+ `, id, domain.RolePrimary)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to query artist singles: %w", err)
+ }
+ defer singlesRows.Close()
+ var singleIDs []domain.SongID
+ for singlesRows.Next() {
+ var songID domain.SongID
+ if err := singlesRows.Scan(&songID); err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to scan single id: %w", err)
+ }
+ singleIDs = append(singleIDs, songID)
+ }
+ if err := singlesRows.Err(); err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to iterate artist singles: %w", err)
+ }
+ singlesRows.Close()
+
+ singles := make([]domain.Song, 0, len(singleIDs))
+ for _, songID := range singleIDs {
+ song, err := GetSong(e, songID)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get single %d: %w", songID, err)
+ }
+ singles = append(singles, song)
+ }
+
+ // fetch featured songs (featured role, regardless of album) ordered by release_date
+ featuredRows, err := e.Query(`
+ select sa.song_id
+ from song_artists sa
+ join songs s on s.id = sa.song_id
+ where sa.artist_id = ?
+ and sa.role = ?
+ order by s.release_date
+ `, id, domain.RoleFeatured)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to query featured songs: %w", err)
+ }
+ defer featuredRows.Close()
+ var featuredIDs []domain.SongID
+ for featuredRows.Next() {
+ var songID domain.SongID
+ if err := featuredRows.Scan(&songID); err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to scan featured song id: %w", err)
+ }
+ featuredIDs = append(featuredIDs, songID)
+ }
+ if err := featuredRows.Err(); err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to iterate featured songs: %w", err)
+ }
+ featuredRows.Close()
+
+ featured := make([]domain.Song, 0, len(featuredIDs))
+ for _, songID := range featuredIDs {
+ song, err := GetSong(e, songID)
+ if err != nil {
+ return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get featured song %d: %w", songID, err)
+ }
+ featured = append(featured, song)
+ }
+
+ return artist, albums, singles, featured, nil
+}
+
func InsertArtist(e Execer, artist domain.Artist) error {
_, err := e.Exec(`
insert into artists (
diff --git a/internal/domain/album.go b/internal/domain/album.go
@@ -4,17 +4,33 @@ import "time"
type AlbumID int64
+// AlbumRecord is the write model used for inserts.
+type AlbumRecord struct {
+ ID AlbumID
+ Title string
+ LatinName *string
+ ReleaseDate time.Time
+ Artists []AlbumRecordArtist
+}
+
+type AlbumRecordArtist struct {
+ ArtistID ArtistID
+ Role ArtistRole
+ SortOrder int
+}
+
+// Album is the full read model with all related data embedded.
type Album struct {
ID AlbumID
Title string
LatinName *string
ReleaseDate time.Time
Artists []AlbumArtist
+ Songs []Song
}
type AlbumArtist struct {
- AlbumID AlbumID
- ArtistID ArtistID
+ Artist Artist
Role ArtistRole
SortOrder int
}
diff --git a/internal/server/artist.go b/internal/server/artist.go
@@ -0,0 +1,50 @@
+package server
+
+import (
+ "fmt"
+ "lyrics/internal/db"
+ "lyrics/internal/domain"
+ "lyrics/internal/server/htmx"
+ artistview "lyrics/internal/server/ui/views/artist"
+ "net/http"
+ "strconv"
+)
+
+func (ss *ServerState) Artist(w http.ResponseWriter, r *http.Request) {
+ logger := ss.logger.With("handler", "Artist")
+
+ idStr := r.PathValue("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ logger.Error("failed to convert artist id to integer", "id", idStr, "err", err)
+ htmx.HxError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ slug := r.PathValue("slug")
+ logger = logger.With("id", id, "slug", slug)
+
+ artist, albums, singles, featured, err := db.GetArtist(ss.db, domain.ArtistID(id))
+ if err != nil {
+ logger.Error("failed to get artist", "err", err)
+ htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for artist '%s'", slug))
+ return
+ }
+
+ artistName := artist.Name
+ if artist.LatinName != nil {
+ artistName = *artist.LatinName
+ }
+
+ if domain.Slug(artistName) != slug {
+ htmx.HxRedirect(w, http.StatusMovedPermanently, fmt.Sprintf("/artist/%d/%s", id, domain.Slug(artistName)))
+ return
+ }
+
+ artistview.Artist(artistview.ArtistParams{
+ Artist: artist,
+ Albums: albums,
+ Singles: singles,
+ Featured: featured,
+ }).Render(r.Context(), w)
+}
diff --git a/internal/server/new-album.go b/internal/server/new-album.go
@@ -17,8 +17,8 @@ type NewAlbumForm struct {
ReleaseDate string `schema:"release-date"`
}
-func parseNewAlbumForm(form NewAlbumForm) (domain.Album, error) {
- var album domain.Album
+func parseNewAlbumForm(form NewAlbumForm) (domain.AlbumRecord, error) {
+ var album domain.AlbumRecord
// title
if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) {
@@ -30,7 +30,7 @@ func parseNewAlbumForm(form NewAlbumForm) (domain.Album, error) {
return album, fmt.Errorf("At least one artist is required")
}
- artists := make([]domain.AlbumArtist, 0, len(form.ArtistID))
+ artists := make([]domain.AlbumRecordArtist, 0, len(form.ArtistID))
seenArtists := make(map[int64]struct{}, len(form.ArtistID))
for i, id := range form.ArtistID {
@@ -42,7 +42,7 @@ func parseNewAlbumForm(form NewAlbumForm) (domain.Album, error) {
}
seenArtists[int64(id)] = struct{}{}
- artists = append(artists, domain.AlbumArtist{
+ artists = append(artists, domain.AlbumRecordArtist{
ArtistID: id,
Role: domain.RolePrimary,
SortOrder: i,
@@ -72,7 +72,7 @@ func parseNewAlbumForm(form NewAlbumForm) (domain.Album, error) {
minDate.Format("2006-01-02"), maxDate.Format("2006-01-02"))
}
- album = domain.Album{
+ album = domain.AlbumRecord{
Title: form.Title,
LatinName: latinTitle,
ReleaseDate: releaseDate,
diff --git a/internal/server/router.go b/internal/server/router.go
@@ -29,6 +29,9 @@ func (ss *ServerState) Router() http.Handler {
})
router.Route("/artist", func(router chi.Router) {
+ router.Route("/{id}", func(router chi.Router) {
+ router.Get("/{slug}", ss.Artist)
+ })
router.Get("/new", ss.NewArtist)
router.Post("/new", ss.NewArtist)
router.Get("/search", ss.SearchArtist)
diff --git a/internal/server/ui/views/artist/artist.go b/internal/server/ui/views/artist/artist.go
@@ -0,0 +1,10 @@
+package artist
+
+import "lyrics/internal/domain"
+
+type ArtistParams struct {
+ Artist domain.Artist
+ Albums []domain.Album
+ Singles []domain.Song
+ Featured []domain.Song
+}
diff --git a/internal/server/ui/views/artist/artist.templ b/internal/server/ui/views/artist/artist.templ
@@ -0,0 +1,104 @@
+package artist
+
+import (
+ "fmt"
+ "lyrics/internal/server/ui/layouts/base"
+ "time"
+)
+
+templ Artist(params ArtistParams) {
+ @base.Base(base.BaseParams{Title: params.Artist.Name}) {
+ <article>
+ <header>
+ <hgroup>
+ <h1>
+ { params.Artist.Name }
+ if params.Artist.LatinName != nil {
+ <span>({ *params.Artist.LatinName })</span>
+ }
+ </h1>
+ </hgroup>
+ if params.Artist.Disambiguation != nil {
+ <p>{ *params.Artist.Disambiguation }</p>
+ }
+ if params.Artist.Bio != nil {
+ <p>{ *params.Artist.Bio }</p>
+ }
+ </header>
+ <section>
+ <h2>Discography</h2>
+ for _, album := range params.Albums {
+ <article>
+ <header>
+ <hgroup>
+ <h3>
+ <a href={ fmt.Sprintf("/album/%d/%s", album.ID, album.Title) }>{ album.Title }</a>
+ </h3>
+ if album.LatinName != nil {
+ <span>({ *album.LatinName })</span>
+ }
+ </hgroup>
+ <ul>
+ for _, artist := range album.Artists {
+ if artist.Artist.ID != params.Artist.ID {
+ <li>
+ <a href={ fmt.Sprintf("/artist/%d/%s", artist.Artist.ID, artist.Artist.Name) }>
+ { artist.Artist.Name }
+ if artist.Artist.LatinName != nil {
+ <span>({ *artist.Artist.LatinName })</span>
+ }
+ </a>
+ </li>
+ }
+ }
+ </ul>
+ <time datetime={ album.ReleaseDate.Format(time.DateOnly) }>{ album.ReleaseDate.Format("Jan 2, 2006") }</time>
+ </header>
+ <ol>
+ for _, song := range album.Songs {
+ <li>
+ <a href={ fmt.Sprintf("/song/%d/%s", song.ID, song.Title) }>
+ { song.Title }
+ if song.LatinTitle != nil {
+ <span>({ *song.LatinTitle })</span>
+ }
+ </a>
+ </li>
+ }
+ </ol>
+ </article>
+ }
+ </section>
+ <section>
+ <h2>Singles</h2>
+ <ul>
+ for _, song := range params.Singles {
+ <li>
+ <a href={ fmt.Sprintf("/song/%d/%s", song.ID, song.Title) }>
+ { song.Title }
+ if song.LatinTitle != nil {
+ <span>({ *song.LatinTitle })</span>
+ }
+ </a>
+ </li>
+ }
+ </ul>
+ </section>
+ <section>
+ <h2>Featured On</h2>
+ <ul>
+ for _, song := range params.Featured {
+ <li>
+ <a href={ fmt.Sprintf("/song/%d/%s", song.ID, song.Title) }>
+ { song.Title }
+ if song.LatinTitle != nil {
+ <span>({ *song.LatinTitle })</span>
+ }
+ </a>
+ </li>
+ }
+ </ul>
+ </section>
+ </article>
+ }
+}