lyrics

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

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:
Minternal/db/album.go | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Minternal/db/artist.go | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/domain/album.go | 20++++++++++++++++++--
Ainternal/server/artist.go | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/new-album.go | 10+++++-----
Minternal/server/router.go | 3+++
Ainternal/server/ui/views/artist/artist.go | 10++++++++++
Ainternal/server/ui/views/artist/artist.templ | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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> + } +}