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