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