lyrics

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

commit 03b390e3b3ba0d737f9cf891b44eb9c6098a42c7
parent 1da8e98eca8f0e682cfeb5c63aeac5e51e0af938
Author: brookjeynes <me@brookjeynes.dev>
Date:   Sun,  3 May 2026 21:38:55 +1000

feat: add disambiguation field for artists

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

Diffstat:
Minternal/db/artist.go | 12+++++++-----
Minternal/db/db.go | 1+
Minternal/domain/artist.go | 9+++++----
Minternal/server/new-artist.go | 19+++++++++++++------
Minternal/server/new-song.go | 3+++
Minternal/server/search-artist.go | 20++++++++++++++++----
Minternal/server/ui/partials/artist-search-datalist/artist-search-datalist.templ | 17+++++++++++++++--
Minternal/server/ui/partials/similar-artists-list/similar-artists-list.templ | 19+++++++++++++++++--
Minternal/server/ui/views/new-artist/new-artist.templ | 11++++++++++-
Minternal/server/ui/views/new-song/new-song.templ | 2+-
10 files changed, 88 insertions(+), 25 deletions(-)

diff --git a/internal/db/artist.go b/internal/db/artist.go @@ -12,11 +12,12 @@ func InsertArtist(e Execer, artist domain.Artist) error { insert into artists ( name, latin_name, + disambiguation, bio, created_at ) - values (?, ?, ?, ?) - `, artist.Name, artist.LatinName, artist.Bio, time.Now().Format(time.RFC3339)) + values (?, ?, ?, ?, ?) + `, artist.Name, artist.LatinName, artist.Disambiguation, artist.Bio, time.Now().Format(time.RFC3339)) if err != nil { return fmt.Errorf("failed to insert artist: %w", err) } @@ -30,9 +31,10 @@ func UpdateArtist(e Execer, artist domain.Artist) error { set name = ?, latin_name = ?, + disambiguation = ?, bio = ? where id = ? - `, artist.Name, artist.LatinName, artist.Bio, artist.ID) + `, artist.Name, artist.LatinName, artist.Disambiguation, artist.Bio, artist.ID) if err != nil { return fmt.Errorf("failed to update artist: %w", err) } @@ -48,7 +50,7 @@ func SearchArtist(e Execer, artistName string) ([]domain.Artist, error) { wildcard := "%" + artistName + "%" rows, err := e.Query(` - select id, name, latin_name, bio + select id, name, latin_name, disambiguation, bio from artists where name like ? collate nocase or latin_name like ? collate nocase @@ -63,7 +65,7 @@ func SearchArtist(e Execer, artistName string) ([]domain.Artist, error) { var results []domain.Artist for rows.Next() { var artist domain.Artist - if err := rows.Scan(&artist.ID, &artist.Name, &artist.LatinName, &artist.Bio); err != nil { + if err := rows.Scan(&artist.ID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio); err != nil { return nil, fmt.Errorf("failed to scan artist row: %w", err) } results = append(results, artist) diff --git a/internal/db/db.go b/internal/db/db.go @@ -77,6 +77,7 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { id integer primary key autoincrement, name text not null, latin_name text, + disambiguation text, bio text, created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); diff --git a/internal/domain/artist.go b/internal/domain/artist.go @@ -1,8 +1,9 @@ package domain type Artist struct { - ID int64 - Name string - LatinName *string - Bio *string + ID int64 + Name string + LatinName *string + Disambiguation *string + Bio *string } diff --git a/internal/server/new-artist.go b/internal/server/new-artist.go @@ -10,9 +10,10 @@ import ( ) type NewArtistForm struct { - Name string `schema:"artist"` - Bio string `schema:"biography"` - LatinName string `schema:"latin-name"` + Name string `schema:"artist"` + Bio string `schema:"biography"` + LatinName string `schema:"latin-name"` + Disambiguation string `schema:"disambiguation"` } func parseNewArtistForm(form NewArtistForm) (domain.Artist, error) { @@ -33,10 +34,16 @@ func parseNewArtistForm(form NewArtistForm) (domain.Artist, error) { return artist, fmt.Errorf("Artist biography must not be empty or have leading/trailing whitespace") } + // disambiguation + if form.Disambiguation != "" && !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Disambiguation) { + return artist, fmt.Errorf("Artist disambiguation must not be empty or have leading/trailing whitespace") + } + artist = domain.Artist{ - Name: form.Name, - LatinName: &form.LatinName, - Bio: &form.Bio, + Name: form.Name, + LatinName: &form.LatinName, + Bio: &form.Bio, + Disambiguation: &form.Disambiguation, } return artist, nil diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -33,14 +33,17 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } } + // title if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { return song, fmt.Errorf("Title must not be empty or have leading/trailing whitespace") } + // latin title if form.LatinTitle != "" && !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { return song, fmt.Errorf("Latin title must not be empty") } + // lyrics if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Lyrics) { return song, fmt.Errorf("Lyrics must not be empty") } diff --git a/internal/server/search-artist.go b/internal/server/search-artist.go @@ -5,7 +5,13 @@ import ( artistsearchdatalist "lyrics/internal/server/ui/partials/artist-search-datalist" similarartistslist "lyrics/internal/server/ui/partials/similar-artists-list" "net/http" - "strings" +) + +type SearchArtistFormat string + +const ( + SearchArtistFormatSimilar SearchArtistFormat = "similar" + SearchArtistFormatSuggest SearchArtistFormat = "suggest" ) func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { @@ -13,6 +19,12 @@ func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + format := SearchArtistFormat(r.URL.Query().Get("format")) + if format != SearchArtistFormatSimilar && format != SearchArtistFormatSuggest { + http.Error(w, "invalid format param", http.StatusBadRequest) + return + } + artist := r.URL.Query().Get("artist") results, err := db.SearchArtist(ss.db, artist) @@ -21,12 +33,12 @@ func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { return } - requestFromUrl := r.Header.Get("HX-Current-URL") - if strings.Contains(requestFromUrl, "/artist/new") { + switch format { + case SearchArtistFormatSimilar: similarartistslist.SimilarArtistsList(similarartistslist.SimilarArtistsListParams{ Artists: results, }).Render(r.Context(), w) - } else { + case SearchArtistFormatSuggest: artistsearchdatalist.ArtistSearchDatalist(artistsearchdatalist.ArtistSearchDatalistParams{ Artists: results, }).Render(r.Context(), w) diff --git a/internal/server/ui/partials/artist-search-datalist/artist-search-datalist.templ b/internal/server/ui/partials/artist-search-datalist/artist-search-datalist.templ @@ -1,9 +1,22 @@ package artistsearchdatalist +import "fmt" + templ ArtistSearchDatalist(params ArtistSearchDatalistParams) { <datalist id="artist-suggestions"> - for _, a := range params.Artists { - <option value={ a.Name }></option> + for _, artist := range params.Artists { + {{ + value := artist.Name + + if artist.LatinName != nil && artist.Disambiguation != nil { + value = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation) + } else if artist.LatinName != nil { + value = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName) + } else if artist.Disambiguation != nil { + value = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation) + } + }} + <option value={ value }></option> } </datalist> } diff --git a/internal/server/ui/partials/similar-artists-list/similar-artists-list.templ b/internal/server/ui/partials/similar-artists-list/similar-artists-list.templ @@ -1,9 +1,24 @@ package similarartistslist +import "fmt" + templ SimilarArtistsList(params SimilarArtistsListParams) { <ul id="similar-artists"> - for _, a := range params.Artists { - <li>{ a.Name }</li> + for _, artist := range params.Artists { + {{ + value := artist.Name + + if artist.LatinName != nil && artist.Disambiguation != nil { + value = fmt.Sprintf("%s (%s, %s)", artist.Name, *artist.LatinName, *artist.Disambiguation) + } else if artist.LatinName != nil { + value = fmt.Sprintf("%s (%s)", artist.Name, *artist.LatinName) + } else if artist.Disambiguation != nil { + value = fmt.Sprintf("%s (%s)", artist.Name, *artist.Disambiguation) + } + }} + <li title={ value }> + { value } + </li> } </ul> } diff --git a/internal/server/ui/views/new-artist/new-artist.templ b/internal/server/ui/views/new-artist/new-artist.templ @@ -17,7 +17,7 @@ templ NewArtist(params NewArtistParams) { <label> artist: <input - hx-get="/artist/search" + hx-get="/artist/search?format=similar" hx-trigger="input changed delay:500ms, load" hx-target="#similar-artists" hx-swap="outerHTML" @@ -38,6 +38,15 @@ templ NewArtist(params NewArtistParams) { /> </label> </div> + <label title="helps users distinguish between artists with identical or similar names, e.g. South Korean singer-songwriter"> + disambiguation: + <input + name="disambiguation" + type="text" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + title="helps users distinguish between artists with identical or similar names, e.g. South Korean singer-songwriter" + /> + </label> <div> <span>similar artists</span> <ul id="similar-artists"></ul> diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ @@ -26,7 +26,7 @@ templ NewSong(params NewSongParams) { <label> <span>artist</span> <input - hx-get="/artist/search" + hx-get="/artist/search?format=suggest" hx-trigger="input changed delay:500ms, load" hx-target="#artist-suggestions" hx-swap="outerHTML"