lyrics

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

commit 3fff705c8b00eb5656cffed1eac851d945d968df
parent 53a2a72d5fe6ba242bfbbcf5a7e73da5a95cf65c
Author: brookjeynes <me@brookjeynes.dev>
Date:   Fri,  8 May 2026 09:36:33 +1000

feat(ui): create song page

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

Diffstat:
Mgo.mod | 1+
Mgo.sum | 2++
Minternal/db/song.go | 189++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Minternal/domain/song.go | 30++++++++++++++++++++++++++----
Minternal/server/htmx/htmx.go | 5+++--
Minternal/server/new-album.go | 2+-
Minternal/server/new-artist.go | 2+-
Minternal/server/new-song.go | 18+++++++++---------
Minternal/server/router.go | 9+++++++--
Ainternal/server/song.go | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/ui/views/song/song.go | 7+++++++
Ainternal/server/ui/views/song/song.templ | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 376 insertions(+), 20 deletions(-)

diff --git a/go.mod b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gorilla/schema v1.4.1 github.com/mattn/go-sqlite3 v1.14.44 github.com/sethvargo/go-envconfig v1.3.0 + golang.org/x/text v0.27.0 ) require ( diff --git a/go.sum b/go.sum @@ -74,6 +74,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/db/song.go b/internal/db/song.go @@ -3,10 +3,197 @@ package db import ( "fmt" "lyrics/internal/domain" + "strings" "time" ) -func InsertSong(e Execer, song domain.Song) error { +func GetSong(e Execer, id domain.SongID) (domain.Song, error) { + var song domain.Song + var releaseDateStr string + + err := e.QueryRow(` + select id, title, latin_title, lyrics, release_date + from songs + where id = ? + `, id).Scan(&song.ID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr) + if err != nil { + return domain.Song{}, fmt.Errorf("failed to get song: %w", err) + } + + song.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr) + if err != nil { + 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 + `, id) + if err != nil { + return domain.Song{}, fmt.Errorf("failed to query song artists: %w", err) + } + defer rows.Close() + for rows.Next() { + var r songArtistRow + if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil { + return domain.Song{}, fmt.Errorf("failed to scan song artist: %w", err) + } + artistRows = append(artistRows, r) + } + if err := rows.Err(); err != nil { + return domain.Song{}, fmt.Errorf("failed to iterate song 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.Song{}, 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.Song{}, fmt.Errorf("failed to scan artist: %w", err) + } + artistMap[a.ID] = a + } + if err := aRows.Err(); err != nil { + return domain.Song{}, fmt.Errorf("failed to iterate artists: %w", err) + } + aRows.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 + + alRows, err := e.Query(` + select album_id, track_number + from song_albums + where song_id = ? + `, id) + if err != nil { + return domain.Song{}, fmt.Errorf("failed to query song albums: %w", err) + } + defer alRows.Close() + for alRows.Next() { + var r songAlbumRow + if err := alRows.Scan(&r.albumID, &r.trackNumber); err != nil { + return domain.Song{}, fmt.Errorf("failed to scan song album: %w", err) + } + albumRows = append(albumRows, r) + } + if err := alRows.Err(); err != nil { + return domain.Song{}, fmt.Errorf("failed to iterate song albums: %w", err) + } + alRows.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 + } + placeholders := strings.Repeat("?,", len(albumIDs)) + placeholders = placeholders[:len(placeholders)-1] + + albumMap := make(map[domain.AlbumID]domain.Album, len(albumIDs)) + abRows, err := e.Query(` + select id, title, latin_name, release_date + from albums + where id in (`+placeholders+`) + `, albumIDs...) + if err != nil { + return domain.Song{}, fmt.Errorf("failed to query albums: %w", err) + } + defer abRows.Close() + for abRows.Next() { + var a domain.Album + var relDate string + if err := abRows.Scan(&a.ID, &a.Title, &a.LatinName, &relDate); err != nil { + return domain.Song{}, fmt.Errorf("failed to scan album: %w", err) + } + a.ReleaseDate, err = time.Parse(time.RFC3339, relDate) + if err != nil { + return domain.Song{}, fmt.Errorf("failed to parse album release_date: %w", err) + } + albumMap[a.ID] = a + } + if err := abRows.Err(); err != nil { + return domain.Song{}, fmt.Errorf("failed to iterate albums: %w", err) + } + abRows.Close() + + song.Albums = make([]domain.SongAlbum, len(albumRows)) + for i, r := range albumRows { + song.Albums[i] = domain.SongAlbum{ + Album: albumMap[r.albumID], + TrackNumber: r.trackNumber, + } + } + } + + // fetch genres + gRows, err := e.Query(` + select genre + from song_genres + where song_id = ? + `, id) + if err != nil { + return domain.Song{}, fmt.Errorf("failed to query song genres: %w", err) + } + defer gRows.Close() + for gRows.Next() { + var g domain.Genre + if err := gRows.Scan(&g); err != nil { + return domain.Song{}, fmt.Errorf("failed to scan song genre: %w", err) + } + song.Genres = append(song.Genres, g) + } + if err := gRows.Err(); err != nil { + return domain.Song{}, fmt.Errorf("failed to iterate song genres: %w", err) + } + + return song, nil +} + +func InsertSong(e Execer, song domain.SongRecord) error { result, err := e.Exec(` insert into songs ( title, diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -6,6 +6,30 @@ import ( type SongID int64 +// SongRecord is the write model used for inserts. +type SongRecord struct { + ID SongID + Title string + LatinTitle *string + Lyrics string + ReleaseDate time.Time + Artists []SongRecordArtist + Albums []SongRecordAlbum + Genres []Genre +} + +type SongRecordArtist struct { + ArtistID ArtistID + Role ArtistRole + SortOrder int +} + +type SongRecordAlbum struct { + AlbumID AlbumID + TrackNumber *int +} + +// Song is the full read model with all related data embedded. type Song struct { ID SongID Title string @@ -18,14 +42,12 @@ type Song struct { } type SongArtist struct { - SongID SongID - ArtistID ArtistID + Artist Artist Role ArtistRole SortOrder int } type SongAlbum struct { - SongID SongID - AlbumID AlbumID + Album Album TrackNumber *int } diff --git a/internal/server/htmx/htmx.go b/internal/server/htmx/htmx.go @@ -18,7 +18,8 @@ func HxError(w http.ResponseWriter, status int, msg string) { w.Write([]byte(msg)) } -func HxRedirect(w http.ResponseWriter, location string) { +func HxRedirect(w http.ResponseWriter, status int, location string) { w.Header().Set("HX-Redirect", location) - w.WriteHeader(http.StatusOK) + w.Header().Set("Location", location) + w.WriteHeader(status) } diff --git a/internal/server/new-album.go b/internal/server/new-album.go @@ -136,6 +136,6 @@ func (ss *ServerState) NewAlbum(w http.ResponseWriter, r *http.Request) { logger.Info("added album", "album", newAlbum) - htmx.HxRedirect(w, "/") + htmx.HxRedirect(w, http.StatusOK, "/") } } diff --git a/internal/server/new-artist.go b/internal/server/new-artist.go @@ -110,6 +110,6 @@ func (ss *ServerState) NewArtist(w http.ResponseWriter, r *http.Request) { logger.Info("added artist", "artist", newArtist) - htmx.HxRedirect(w, "/") + htmx.HxRedirect(w, http.StatusOK, "/") } } diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -21,15 +21,15 @@ type NewSongForm struct { ReleaseDate string `schema:"release-date"` } -func parseNewSongForm(form NewSongForm) (domain.Song, error) { - var song domain.Song +func parseNewSongForm(form NewSongForm) (domain.SongRecord, error) { + var song domain.SongRecord // artists if len(form.ArtistID) == 0 { return song, fmt.Errorf("At least one artist is required") } - artists := make([]domain.SongArtist, 0, len(form.ArtistID)+len(form.FeaturingID)) + artists := make([]domain.SongRecordArtist, 0, len(form.ArtistID)+len(form.FeaturingID)) seenArtists := make(map[int64]struct{}, len(form.ArtistID)+len(form.FeaturingID)) for i, id := range form.ArtistID { @@ -41,7 +41,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } seenArtists[int64(id)] = struct{}{} - artists = append(artists, domain.SongArtist{ + artists = append(artists, domain.SongRecordArtist{ ArtistID: id, Role: domain.RolePrimary, SortOrder: i, @@ -58,7 +58,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } seenArtists[int64(id)] = struct{}{} - artists = append(artists, domain.SongArtist{ + artists = append(artists, domain.SongRecordArtist{ ArtistID: id, Role: domain.RoleFeatured, SortOrder: i, @@ -96,7 +96,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } // albums - albums := make([]domain.SongAlbum, 0, len(form.AlbumID)) + albums := make([]domain.SongRecordAlbum, 0, len(form.AlbumID)) seenAlbums := make(map[int64]struct{}, len(form.AlbumID)) for _, id := range form.AlbumID { if id < 0 { @@ -107,7 +107,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } seenAlbums[int64(id)] = struct{}{} - albums = append(albums, domain.SongAlbum{ + albums = append(albums, domain.SongRecordAlbum{ AlbumID: id, }) } @@ -124,7 +124,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) } - song = domain.Song{ + song = domain.SongRecord{ Title: form.Title, LatinTitle: latinTitle, Lyrics: form.Lyrics, @@ -191,6 +191,6 @@ func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) { logger.Info("added song", "song", newSong) - htmx.HxRedirect(w, "/") + htmx.HxRedirect(w, http.StatusOK, "/") } } diff --git a/internal/server/router.go b/internal/server/router.go @@ -14,8 +14,13 @@ func (ss *ServerState) Router() http.Handler { router.Get("/", ss.Index) - router.Get("/new", ss.NewSong) - router.Post("/new", ss.NewSong) + router.Route("/song", func(router chi.Router) { + router.Route("/{id}", func(router chi.Router) { + router.Get("/{slug}", ss.Song) + }) + router.Get("/new", ss.NewSong) + router.Post("/new", ss.NewSong) + }) router.Route("/album", func(router chi.Router) { router.Get("/new", ss.NewAlbum) diff --git a/internal/server/song.go b/internal/server/song.go @@ -0,0 +1,74 @@ +package server + +import ( + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + "lyrics/internal/server/ui/views/song" + "net/http" + "strconv" + "strings" + "unicode" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +func Slug(title string) string { + // strip apostrophes before normalizing + s := strings.ReplaceAll(title, "'", "") + + // unicode normalize: NFD decompose, strip non-spacing marks, recompose + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + s, _, _ = transform.String(t, s) + + s = strings.ToLower(s) + s = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + return r + } + return '-' + }, s) + + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + + return strings.Trim(s, "-") +} + +func (ss *ServerState) Song(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "Song") + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + logger.Error("failed to convert song 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) + + s, err := db.GetSong(ss.db, domain.SongID(id)) + if err != nil { + logger.Error("failed to get song", "err", err) + htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for song '%s'", slug)) + return + } + + songTitle := s.Title + if s.LatinTitle != nil { + songTitle = *s.LatinTitle + } + + if Slug(songTitle) != slug { + htmx.HxRedirect(w, http.StatusMovedPermanently, fmt.Sprintf("/song/%d/%s", id, Slug(songTitle))) + return + } + + song.Song(song.SongParams{Song: s}).Render(r.Context(), w) +} diff --git a/internal/server/ui/views/song/song.go b/internal/server/ui/views/song/song.go @@ -0,0 +1,7 @@ +package song + +import "lyrics/internal/domain" + +type SongParams struct { + Song domain.Song +} diff --git a/internal/server/ui/views/song/song.templ b/internal/server/ui/views/song/song.templ @@ -0,0 +1,57 @@ +package song + +import ( + "fmt" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ Song(params SongParams) { + @base.Base(base.BaseParams{Title: params.Song.Title}) { + <article> + <header> + <hgroup> + <h1> + { params.Song.Title } + if params.Song.LatinTitle != nil { + <span>({ *params.Song.LatinTitle })</span> + } + </h1> + </hgroup> + <ul> + for _, artist := range params.Song.Artists { + <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={ params.Song.ReleaseDate.Format(time.DateOnly) }>{ params.Song.ReleaseDate.Format("Jan 2, 2006") }</time> + <ul> + for _, genre := range params.Song.Genres { + <li> + { genre } + </li> + } + </ul> + <ul> + for _, album := range params.Song.Albums { + <li> + <a href={ fmt.Sprintf("/album/%d/%s", album.Album.ID, album.Album.Title) }> + { album.Album.Title } + if album.Album.LatinName != nil { + <span>({ *album.Album.LatinName })</span> + } + </a> + </li> + } + </ul> + </header> + <pre>{ params.Song.Lyrics }</pre> + </article> + } +}