lyrics

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

commit fb8f44acd164e1b2f5899fb31e76f0f44a6bce61
parent 3d6d0d855fa7ba3caa35638b1d5a2b408f14cfd5
Author: brookjeynes <me@brookjeynes.dev>
Date:   Mon,  4 May 2026 16:37:05 +1000

feat: save song to db

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

Diffstat:
Ainternal/db/song.go | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/domain/song.go | 2+-
Minternal/server/new-artist.go | 7+++++--
Minternal/server/new-song.go | 51+++++++++++++++++++++++++++++++++++++++++++--------
Minternal/server/search-artist.go | 23+++++++++++++++++++----
Minternal/server/ui/partials/artist-search-results/artist-search-results.templ | 1-
Minternal/server/ui/views/new-artist/new-artist.templ | 2+-
Ainternal/server/ui/views/new-song/new-song.js | 13+++++++++++++
Minternal/server/ui/views/new-song/new-song.templ | 43++++++++++++++++++++++++-------------------
9 files changed, 168 insertions(+), 36 deletions(-)

diff --git a/internal/db/song.go b/internal/db/song.go @@ -0,0 +1,62 @@ +package db + +import ( + "fmt" + "lyrics/internal/domain" + "time" +) + +func InsertSong(e Execer, song domain.Song) error { + result, err := e.Exec(` + insert into songs ( + title, + latin_title, + lyrics, + release_date, + created_at + ) + values (?, ?, ?, ?, ?) + `, song.Title, song.LatinTitle, song.Lyrics, song.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339)) + if err != nil { + return fmt.Errorf("failed to insert song: %w", err) + } + + lastID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to retrieve last inserted id: %w", err) + } + + for _, artist := range song.Artists { + _, err = e.Exec(` + insert into song_artists ( + song_id, + artist_id, + role, + sort_order + ) + values (?, ?, ?, ?) + on conflict(song_id, artist_id, role) do update set + sort_order = excluded.sort_order + `, lastID, artist.ArtistID, artist.Role, artist.SortOrder) + if err != nil { + return fmt.Errorf("failed to insert song artist: %w", err) + } + } + + // todo: albums + + for _, genre := range song.Genres { + _, err = e.Exec(` + insert into song_genres ( + song_id, + genre + ) + values (?, ?) + `, lastID, genre) + if err != nil { + return fmt.Errorf("failed to insert song genre: %w", err) + } + } + + return nil +} diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -6,7 +6,7 @@ import ( ) var NoTrailingOrLeadingSpaceRegex = regexp.MustCompile(`^\S(\s*\S)*$`) -var LatinNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z ]*[a-zA-Z]$`) +var IsLatinStringRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z ]*[a-zA-Z]$`) type Song struct { ID int64 diff --git a/internal/server/new-artist.go b/internal/server/new-artist.go @@ -85,8 +85,11 @@ func (ss *ServerState) NewArtist(w http.ResponseWriter, r *http.Request) { return } - err = db.InsertArtist(ss.db, newArtist) + err = db.InsertArtist(tx, newArtist) if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } logger.Error("failed to save new artist into db", "err", err) htmx.HxError(w, http.StatusBadRequest, "Failed to add artist.") return @@ -98,7 +101,7 @@ func (ss *ServerState) NewArtist(w http.ResponseWriter, r *http.Request) { return } - logger.Debug("added artist", "artist", newArtist) + logger.Info("added artist", "artist", newArtist) htmx.HxRedirect(w, "/") } diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "lyrics/internal/db" "lyrics/internal/domain" "lyrics/internal/server/htmx" newsong "lyrics/internal/server/ui/views/new-song" @@ -10,14 +11,12 @@ import ( ) type NewSongForm struct { - Artist []string `schema:"artist"` ArtistID []int64 `schema:"artist-id"` Title string `schema:"title"` LatinTitle string `schema:"latin-title"` - Albums []string `schema:"albums"` + Albums []string `schema:"album"` Genres []string `schema:"genres"` Lyrics string `schema:"lyrics"` - Featuring []string `schema:"featuring"` FeaturingID []int64 `schema:"featuring-id"` ReleaseDate string `schema:"release-date"` } @@ -29,11 +28,19 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { 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)) + seenArtists := make(map[int64]struct{}, len(form.ArtistID)+len(form.FeaturingID)) + for i, id := range form.ArtistID { - if id == 0 { + if id < 0 { return song, fmt.Errorf("Invalid artist selection") } + if _, exists := seenArtists[id]; exists { + return song, fmt.Errorf("An artist cannot be added more than once") + } + seenArtists[id] = struct{}{} + artists = append(artists, domain.SongArtist{ ArtistID: id, Role: domain.RolePrimary, @@ -43,9 +50,14 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { // featuring for i, id := range form.FeaturingID { - if id == 0 { - return song, fmt.Errorf("Invalid featured artist selection") + if id < 0 { + return song, fmt.Errorf("Invalid artist selection") + } + if _, exists := seenArtists[id]; exists { + return song, fmt.Errorf("An artist cannot be added more than once") } + seenArtists[id] = struct{}{} + artists = append(artists, domain.SongArtist{ ArtistID: id, Role: domain.RoleFeatured, @@ -59,7 +71,7 @@ func parseNewSongForm(form NewSongForm) (domain.Song, error) { } // latin title - if form.LatinTitle != "" && !domain.LatinNameRegex.MatchString(form.LatinTitle) { + if form.LatinTitle != "" && !domain.IsLatinStringRegex.MatchString(form.LatinTitle) { return song, fmt.Errorf("Latin title must only contain letters and spaces") } @@ -152,7 +164,30 @@ func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) { return } - logger.Debug("added song", "song", newSong) + tx, err := ss.db.Begin() + if err != nil { + logger.Error("failed to begin db transaction", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add song.") + return + } + + err = db.InsertSong(tx, newSong) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } + logger.Error("failed to save new song into db", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add song.") + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit db transaction", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to add song.") + return + } + + logger.Info("added song", "song", newSong) htmx.HxRedirect(w, "/") } diff --git a/internal/server/search-artist.go b/internal/server/search-artist.go @@ -2,6 +2,7 @@ package server import ( "lyrics/internal/db" + "lyrics/internal/domain" artistsearchresults "lyrics/internal/server/ui/partials/artist-search-results" similarartistslist "lyrics/internal/server/ui/partials/similar-artists-list" "net/http" @@ -26,11 +27,25 @@ func (ss *ServerState) SearchArtist(w http.ResponseWriter, r *http.Request) { } artist := r.URL.Query().Get("artist") + featuring := r.URL.Query().Get("featuring") - results, err := db.SearchArtist(ss.db, artist) - if err != nil { - logger.Error("failed to query db for artists", "err", err) - return + var results []domain.Artist + var err error + + if artist != "" { + results, err = db.SearchArtist(ss.db, artist) + if err != nil { + logger.Error("failed to query db for artists", "err", err) + return + } + } + + if featuring != "" { + results, err = db.SearchArtist(ss.db, featuring) + if err != nil { + logger.Error("failed to query db for artists", "err", err) + return + } } switch format { 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 @@ -22,7 +22,6 @@ templ ArtistSearchResults(params ArtistSearchResultsParams) { <label> <input type="radio" - :name="'artist-result-' + item.id" value={ fmt.Sprintf("%d", artist.ID) } data-name={ artist.Name } required diff --git a/internal/server/ui/views/new-artist/new-artist.templ b/internal/server/ui/views/new-artist/new-artist.templ @@ -34,7 +34,7 @@ templ NewArtist(params NewArtistParams) { name="latin-name" type="text" :required="showLatinName" - pattern={ domain.LatinNameRegex.String() } + pattern={ domain.IsLatinStringRegex.String() } /> </label> </div> diff --git a/internal/server/ui/views/new-song/new-song.js b/internal/server/ui/views/new-song/new-song.js @@ -0,0 +1,13 @@ +const isLatinStringRegex = /[^\u0000-\u007F]/ + +function isLatinString(string) { + return isLatinStringRegex.test(string) +} + +// filter out unnecessary data from request +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("featuring"); +}); diff --git a/internal/server/ui/views/new-song/new-song.templ b/internal/server/ui/views/new-song/new-song.templ @@ -34,10 +34,9 @@ templ NewSong(params NewSongParams) { hx-trigger="input changed delay:500ms" hx-target="next [role='radiogroup']" hx-swap="outerHTML" - hx-include="[name='group']" name="artist" autocomplete="off" - :required="i === 0 && !selected" + :required="!selected" /> </label> <p x-show="selected"> @@ -57,16 +56,18 @@ templ NewSong(params NewSongParams) { > × </button> - <input type="hidden" name="artist-id" :value="artistId"/> - <input type="hidden" name="group" :value="item.id"/> <div x-show="!selected"> <fieldset role="radiogroup" x-show="false"></fieldset> </div> + <input type="hidden" name="artist-id" :value="artistId"/> </li> </template> </ol> </fieldset> - <div x-data="{ showLatinTitle: false }"> + <fieldset x-data="{ showLatinTitle: false }"> + <legend> + title + </legend> <label> title: <input @@ -74,7 +75,7 @@ templ NewSong(params NewSongParams) { type="text" pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } required - @input="showLatinTitle = /[^\u0000-\u007F]/.test($event.target.value)" + @input="showLatinTitle = isLatinString($event.target.value)" /> </label> <label x-show="showLatinTitle"> @@ -83,10 +84,10 @@ templ NewSong(params NewSongParams) { name="latin-title" type="text" :required="showLatinTitle" - pattern={ domain.LatinNameRegex.String() } + pattern={ domain.IsLatinStringRegex.String() } /> </label> - </div> + </fieldset> <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> <legend> albums @@ -99,7 +100,7 @@ templ NewSong(params NewSongParams) { <span>album</span> <input type="text" - name="albums" + name="album" pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } /> </label> @@ -139,16 +140,16 @@ templ NewSong(params NewSongParams) { <template x-for="(item, i) in items" :key="item.id"> <li x-data="{ selected: false, artistId: '', artistName: '' }"> <label x-show="!selected"> - <span>artist</span> + <span>featuring</span> <input type="search" - :hx-get="'/artist/search?format=suggest&group=' + item.id" + hx-get="/artist/search?format=suggest" hx-trigger="input changed delay:500ms" hx-target="next [role='radiogroup']" hx-swap="outerHTML" - hx-include="[name='featuring-group']" name="featuring" autocomplete="off" + :required="!selected" /> </label> <p x-show="selected"> @@ -156,19 +157,22 @@ templ NewSong(params NewSongParams) { <button type="button" @click="selected = false; artistId = ''; artistName = ''" - >change</button> + > + change + </button> </p> - <input type="hidden" name="featuring-id" :value="artistId"/> - <input type="hidden" name="featuring-group" :value="item.id"/> - <div x-show="!selected"> - <fieldset role="radiogroup" x-show="false"></fieldset> - </div> <button type="button" aria-label="Remove" x-show="items.length > 1" @click="items.splice(i, 1)" - >×</button> + > + × + </button> + <div x-show="!selected"> + <fieldset role="radiogroup" x-show="false"></fieldset> + </div> + <input type="hidden" name="featuring-id" :value="artistId"/> </li> </template> </ol> @@ -187,5 +191,6 @@ templ NewSong(params NewSongParams) { add song </button> </form> + <script src="/static/js/new-song.js" defer></script> } }