lyrics

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

commit 53a2a72d5fe6ba242bfbbcf5a7e73da5a95cf65c
parent e9b41a33f4481c54b123231b5d01e9a155a955b5
Author: brookjeynes <me@brookjeynes.dev>
Date:   Fri,  8 May 2026 08:46:55 +1000

feat: add album search to new song page

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

Diffstat:
Minternal/db/album.go | 2+-
Minternal/db/db.go | 5++---
Minternal/db/song.go | 13++++++++++++-
Minternal/domain/song.go | 8+++++++-
Minternal/server/new-album.go | 1+
Minternal/server/new-song.go | 28++++++++++++++--------------
Minternal/server/search-album.go | 28+++++++++++++++++++++++++---
Ainternal/server/ui/partials/album-search-results/album-search-results.go | 7+++++++
Ainternal/server/ui/partials/album-search-results/album-search-results.templ | 31+++++++++++++++++++++++++++++++
Minternal/server/ui/partials/artist-search-results/artist-search-results.templ | 2+-
Minternal/server/ui/views/new-album/new-album.templ | 4++--
Minternal/server/ui/views/new-song/new-song.js | 1+
Minternal/server/ui/views/new-song/new-song.templ | 29++++++++++++++++++++++++-----
13 files changed, 128 insertions(+), 31 deletions(-)

diff --git a/internal/db/album.go b/internal/db/album.go @@ -16,7 +16,7 @@ func InsertAlbum(e Execer, album domain.Album) error { created_at ) values (?, ?, ?, ?) - `, album.Title, album.LatinName, album.ReleaseDate, time.Now().Format(time.RFC3339)) + `, album.Title, album.LatinName, album.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339)) if err != nil { return fmt.Errorf("failed to insert album: %w", err) } diff --git a/internal/db/db.go b/internal/db/db.go @@ -111,7 +111,7 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { unique(album_id, sort_order), foreign key (album_id) references albums(id) on delete cascade, foreign key (artist_id) references artists(id) on delete cascade, - primary key (album_id, artist_id) + primary key (album_id, artist_id, role) ); `) if err != nil { @@ -152,9 +152,8 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { create table if not exists song_albums ( song_id integer not null, album_id integer not null, - track_number integer not null, + track_number integer, - unique(album_id, track_number), foreign key (song_id) references songs(id) on delete cascade, foreign key (album_id) references albums(id) on delete cascade, primary key (song_id, album_id) diff --git a/internal/db/song.go b/internal/db/song.go @@ -43,7 +43,18 @@ func InsertSong(e Execer, song domain.Song) error { } } - // todo: albums + for _, album := range song.Albums { + _, err = e.Exec(` + insert into song_albums ( + song_id, + album_id + ) + values (?, ?) + `, lastID, album.AlbumID) + if err != nil { + return fmt.Errorf("failed to insert song album: %w", err) + } + } for _, genre := range song.Genres { _, err = e.Exec(` diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -13,7 +13,7 @@ type Song struct { Lyrics string ReleaseDate time.Time Artists []SongArtist - AlbumIDs []AlbumID + Albums []SongAlbum Genres []Genre } @@ -23,3 +23,9 @@ type SongArtist struct { Role ArtistRole SortOrder int } + +type SongAlbum struct { + SongID SongID + AlbumID AlbumID + TrackNumber *int +} diff --git a/internal/server/new-album.go b/internal/server/new-album.go @@ -44,6 +44,7 @@ func parseNewAlbumForm(form NewAlbumForm) (domain.Album, error) { artists = append(artists, domain.AlbumArtist{ ArtistID: id, + Role: domain.RolePrimary, SortOrder: i, }) } diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -14,7 +14,7 @@ type NewSongForm struct { ArtistID []domain.ArtistID `schema:"artist-id"` Title string `schema:"title"` LatinTitle string `schema:"latin-title"` - Albums []string `schema:"album"` + AlbumID []domain.AlbumID `schema:"album-id"` Genres []string `schema:"genres"` Lyrics string `schema:"lyrics"` FeaturingID []domain.ArtistID `schema:"featuring-id"` @@ -82,11 +82,6 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { latinTitle = &form.LatinTitle } - // lyrics - if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Lyrics) { - return song, fmt.Errorf("Lyrics must not be empty") - } - // genres if len(form.Genres) == 0 { return song, fmt.Errorf("At least one genre is required") @@ -101,15 +96,20 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } // albums - var albumIDs []domain.AlbumID - for _, a := range form.Albums { - if a == "" { - continue + albums := make([]domain.SongAlbum, 0, len(form.AlbumID)) + seenAlbums := make(map[int64]struct{}, len(form.AlbumID)) + for _, id := range form.AlbumID { + if id < 0 { + return song, fmt.Errorf("Invalid album selection") } - if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(a) { - return song, fmt.Errorf("Album name must not be empty or have leading/trailing whitespace") + if _, exists := seenAlbums[int64(id)]; exists { + return song, fmt.Errorf("An album cannot be added more than once") } - // TODO: look up or create album + seenAlbums[int64(id)] = struct{}{} + + albums = append(albums, domain.SongAlbum{ + AlbumID: id, + }) } // release date @@ -130,7 +130,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { Lyrics: form.Lyrics, ReleaseDate: releaseDate, Genres: genres, - AlbumIDs: albumIDs, + Albums: albums, Artists: artists, } diff --git a/internal/server/search-album.go b/internal/server/search-album.go @@ -2,13 +2,28 @@ package server import ( "lyrics/internal/db" + albumsearchresults "lyrics/internal/server/ui/partials/album-search-results" similaralbumslist "lyrics/internal/server/ui/partials/similar-albums-list" "net/http" ) +type SearchAlbumFormat string + +const ( + SearchAlbumFormatSimilar SearchAlbumFormat = "similar" + SearchAlbumFormatSuggest SearchAlbumFormat = "suggest" +) + func (ss *ServerState) SearchAlbum(w http.ResponseWriter, r *http.Request) { logger := ss.logger.With("handler", "SearchAlbum") + format := SearchAlbumFormat(r.URL.Query().Get("format")) + if format != SearchAlbumFormatSimilar && format != SearchAlbumFormatSuggest { + logger.Error("invalid album search format", "format", format) + http.Error(w, "invalid format param", http.StatusBadRequest) + return + } + album := r.URL.Query().Get("album") results, err := db.SearchAlbum(ss.db, album) if err != nil { @@ -16,7 +31,14 @@ func (ss *ServerState) SearchAlbum(w http.ResponseWriter, r *http.Request) { return } - similaralbumslist.SimilarAlbumsList(similaralbumslist.SimilarAlbumsListParams{ - Albums: results, - }).Render(r.Context(), w) + switch format { + case SearchAlbumFormatSimilar: + similaralbumslist.SimilarAlbumsList(similaralbumslist.SimilarAlbumsListParams{ + Albums: results, + }).Render(r.Context(), w) + case SearchAlbumFormatSuggest: + albumsearchresults.AlbumSearchResults(albumsearchresults.AlbumSearchResultsParams{ + Albums: results, + }).Render(r.Context(), w) + } } diff --git a/internal/server/ui/partials/album-search-results/album-search-results.go b/internal/server/ui/partials/album-search-results/album-search-results.go @@ -0,0 +1,7 @@ +package albumsearchresults + +import "lyrics/internal/domain" + +type AlbumSearchResultsParams struct { + Albums []domain.Album +} diff --git a/internal/server/ui/partials/album-search-results/album-search-results.templ b/internal/server/ui/partials/album-search-results/album-search-results.templ @@ -0,0 +1,31 @@ +package albumsearchresults + +import "fmt" + +templ AlbumSearchResults(params AlbumSearchResultsParams) { + if len(params.Albums) == 0 { + <fieldset role="radiogroup" x-show="false"></fieldset> + } else { + <fieldset role="radiogroup"> + <legend>select album</legend> + for _, album := range params.Albums { + {{ + label := album.Title + if album.LatinName != nil { + label = fmt.Sprintf("%s (%s)", album.Title, *album.LatinName) + } + }} + <label> + <input + type="radio" + value={ fmt.Sprintf("%d", album.ID) } + data-name={ album.Title } + required + @change="selected = true; albumId = $event.target.value; albumTitle = $event.target.dataset.name" + /> + { label } + </label> + } + </fieldset> + } +} diff --git a/internal/server/ui/partials/artist-search-results/artist-search-results.templ b/internal/server/ui/partials/artist-search-results/artist-search-results.templ @@ -7,7 +7,7 @@ templ ArtistSearchResults(params ArtistSearchResultsParams) { <fieldset role="radiogroup" x-show="false"></fieldset> } else { <fieldset role="radiogroup"> - <legend class="sr-only">select artist</legend> + <legend>select artist</legend> for _, artist := range params.Artists { {{ label := artist.Name diff --git a/internal/server/ui/views/new-album/new-album.templ b/internal/server/ui/views/new-album/new-album.templ @@ -19,7 +19,7 @@ templ NewAlbum(params NewAlbumParams) { <label> album: <input - hx-get="/album/search" + hx-get="/album/search?format=similar" hx-trigger="input changed delay:500ms, load" hx-target="#similar-albums" hx-swap="outerHTML" @@ -107,6 +107,6 @@ templ NewAlbum(params NewAlbumParams) { add album </button> </form> - <script src="/static/js/new-song.js" defer></script> + <script src="/static/js/new-album.js" defer></script> } } diff --git a/internal/server/ui/views/new-song/new-song.js b/internal/server/ui/views/new-song/new-song.js @@ -3,5 +3,6 @@ const form = document.querySelector("form"); form.addEventListener("htmx:config:request", function(evt) { if (!evt.detail.ctx.request.action.startsWith("/new")) return; evt.detail.ctx.request.body.delete("artist"); + evt.detail.ctx.request.body.delete("album"); evt.detail.ctx.request.body.delete("featuring"); }); diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ @@ -96,16 +96,31 @@ templ NewSong(params NewSongParams) { <ol> <template x-for="(item, i) in items" :key="item.id"> <li - x-init="initLucideIcons()" + x-data="{ selected: false, albumId: '', albumTitle: '' }" + x-init="htmx.process($el); initLucideIcons()" > - <label> + <label x-show="!selected"> <span>album</span> <input - type="text" + type="search" + hx-get="/album/search?format=suggest" + hx-trigger="input changed delay:500ms" + hx-target="next [role='radiogroup']" + hx-swap="outerHTML" name="album" - pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + autocomplete="off" + :required="!selected" /> </label> + <p x-show="selected"> + <span x-text="albumTitle"></span> + <button + type="button" + @click="selected = false; albumId = ''; albumTitle = ''" + > + change + </button> + </p> <button type="button" aria-label="Remove" @@ -114,6 +129,10 @@ templ NewSong(params NewSongParams) { > <i data-lucide="x"></i> </button> + <div x-show="!selected"> + <fieldset role="radiogroup" x-show="false"></fieldset> + </div> + <input type="hidden" name="album-id" :value="albumId"/> </li> </template> </ol> @@ -156,7 +175,7 @@ templ NewSong(params NewSongParams) { hx-swap="outerHTML" name="featuring" autocomplete="off" - :required="!selected" + :required="i !== 0 && !selected" /> </label> <p x-show="selected">