lyrics

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

commit 89c9fa25ffa7bd3929b60e02c4704b82808fd40c
parent af0dcec372cd34daf5d1c50f6fce7eaf1e0b34b1
Author: brookjeynes <me@brookjeynes.dev>
Date:   Sat, 13 Jun 2026 13:58:49 +1000

feat(albums): add album editing

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

Diffstat:
Minternal/db/album.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Minternal/db/db.go | 5+++--
Minternal/db/song.go | 15++++++++++++---
Minternal/domain/album.go | 21++++++++++++++++++++-
Minternal/domain/song.go | 4++--
Ainternal/server/edit-album.go | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/router.go | 2++
Minternal/server/routes/routes.go | 4++++
Minternal/server/ui/views/album/album.templ | 13++++++++-----
Minternal/server/ui/views/artist/artist.templ | 10+++++-----
Ainternal/server/ui/views/edit-album/edit-album.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/ui/views/edit-album/edit-album.js | 9+++++++++
Ainternal/server/ui/views/edit-album/edit-album.templ | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/build-assets.sh | 1+
14 files changed, 589 insertions(+), 22 deletions(-)

diff --git a/internal/db/album.go b/internal/db/album.go @@ -107,10 +107,9 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) { // fetch songs on this album ordered by track_number type albumSongRow struct { songID domain.SongID - trackNumber *int + trackNumber int } var songRows []albumSongRow - { rows, err := e.Query(` select song_id, track_number @@ -137,18 +136,91 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) { _ = rows.Close() } - album.Songs = make([]domain.Song, 0, len(songRows)) + album.Songs = make([]domain.AlbumSong, 0, len(songRows)) for _, r := range songRows { song, err := Song(e, r.songID) if err != nil { return domain.Album{}, fmt.Errorf("failed to get song %d: %w", r.songID, err) } - album.Songs = append(album.Songs, song) + album.Songs = append(album.Songs, domain.AlbumSong{ + Song: song, + TrackNumber: r.trackNumber, + }) } return album, nil } +func UpdateAlbum(e Execer, album domain.EditAlbum) error { + result, err := e.Exec(` + update albums + set + title = ?, + latin_title = ?, + release_date = ? + where id = ? + `, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), album.ID) + if err != nil { + return fmt.Errorf("failed to update album: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check updated album rows: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("album %d does not exist", album.ID) + } + + _, err = e.Exec(` + delete from album_artists + where album_id = ? + and role = ? + `, album.ID, domain.RolePrimary) + if err != nil { + return fmt.Errorf("failed to delete album primary artists: %w", err) + } + + for _, artist := range album.Artists { + _, err = e.Exec(` + insert into album_artists ( + album_id, + artist_id, + role, + sort_order + ) + values (?, ?, ?, ?) + `, album.ID, artist.ArtistID, artist.Role, artist.SortOrder) + if err != nil { + return fmt.Errorf("failed to insert album artist: %w", err) + } + } + + _, err = e.Exec(` + delete from song_albums + where album_id = ? + `, album.ID) + if err != nil { + return fmt.Errorf("failed to delete album songs: %w", err) + } + + for _, song := range album.Songs { + _, err = e.Exec(` + insert into song_albums ( + song_id, + album_id, + track_number + ) + values (?, ?, ?) + `, song.SongID, album.ID, song.TrackNumber) + if err != nil { + return fmt.Errorf("failed to update album song: %w", err) + } + } + + return nil +} + func InsertAlbum(e Execer, album domain.NewAlbum) error { result, err := e.Exec(` insert into albums ( diff --git a/internal/db/db.go b/internal/db/db.go @@ -108,7 +108,7 @@ func New(ctx context.Context, dbPath string) (*DB, error) { role text not null, sort_order integer not null, - unique(album_id, sort_order), + unique(album_id, role, 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, role) @@ -152,8 +152,9 @@ func New(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, + track_number integer not null, + 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 @@ -333,10 +333,19 @@ func InsertSong(e Execer, song domain.NewSong) error { _, err = e.Exec(` insert into song_albums ( song_id, - album_id + album_id, + track_number ) - values (?, ?) - `, lastID, album.AlbumID) + values ( + ?, + ?, + coalesce(( + select max(track_number) + from song_albums + where album_id = ? + ), 0) + 1 + ) + `, lastID, album.AlbumID, album.AlbumID) if err != nil { return fmt.Errorf("failed to insert song album: %w", err) } diff --git a/internal/domain/album.go b/internal/domain/album.go @@ -18,13 +18,27 @@ type NewAlbumArtist struct { SortOrder int } +type EditAlbum struct { + ID AlbumID + Title string + LatinTitle *string + ReleaseDate time.Time + Artists []NewAlbumArtist + Songs []EditAlbumSong +} + +type EditAlbumSong struct { + SongID SongID + TrackNumber int +} + type Album struct { ID AlbumID Title string LatinTitle *string ReleaseDate time.Time Artists []AlbumArtist - Songs []Song + Songs []AlbumSong } type AlbumArtist struct { @@ -32,3 +46,8 @@ type AlbumArtist struct { Role ArtistRole SortOrder int } + +type AlbumSong struct { + Song Song + TrackNumber int +} diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -25,7 +25,7 @@ type NewSongArtist struct { type NewSongAlbum struct { AlbumID AlbumID - TrackNumber *int + TrackNumber int } type Song struct { @@ -47,5 +47,5 @@ type SongArtist struct { type SongAlbum struct { Album Album - TrackNumber *int + TrackNumber int } diff --git a/internal/server/edit-album.go b/internal/server/edit-album.go @@ -0,0 +1,195 @@ +package server + +import ( + "context" + "errors" + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + editalbum "lyrics/internal/server/ui/views/edit-album" + "net/http" + "strconv" + "time" +) + +type EditAlbumForm struct { + Title string `schema:"album"` + LatinTitle string `schema:"latin-title"` + ArtistID []domain.ArtistID `schema:"artist-id"` + SongID []domain.SongID `schema:"song-id"` + ReleaseDate string `schema:"release-date"` +} + +func parseEditAlbumForm(form EditAlbumForm) (domain.EditAlbum, error) { + var album domain.EditAlbum + + // title + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { + return album, fmt.Errorf("Title name must not be empty or have leading/trailing whitespace") + } + + // artists + if len(form.ArtistID) == 0 { + return album, fmt.Errorf("At least one artist is required") + } + + artists := make([]domain.NewAlbumArtist, 0, len(form.ArtistID)) + seenArtists := make(map[int64]struct{}, len(form.ArtistID)) + + for i, id := range form.ArtistID { + if id < 0 { + return album, fmt.Errorf("Invalid artist selection") + } + if _, exists := seenArtists[int64(id)]; exists { + return album, fmt.Errorf("An artist cannot be added more than once") + } + seenArtists[int64(id)] = struct{}{} + + artists = append(artists, domain.NewAlbumArtist{ + ArtistID: id, + Role: domain.RolePrimary, + SortOrder: i, + }) + } + + // latin title + var latinTitle *string + if form.LatinTitle != "" { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { + return album, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace") + } + if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { + return album, fmt.Errorf("Latin title contains invalid characters") + } + latinTitle = &form.LatinTitle + } + + releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) + if err != nil { + return album, fmt.Errorf("Invalid release date: %w", err) + } + minDate := time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC) + maxDate := time.Now().AddDate(3, 0, 0) + if releaseDate.Before(minDate) || releaseDate.After(maxDate) { + return album, fmt.Errorf("Release date must be between %s and %s", + minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) + } + + songs := make([]domain.EditAlbumSong, 0, len(form.SongID)) + seenSongs := make(map[int64]struct{}, len(form.SongID)) + + for i, id := range form.SongID { + if id < 0 { + return album, fmt.Errorf("Invalid song selection") + } + if _, exists := seenSongs[int64(id)]; exists { + return album, fmt.Errorf("A song cannot be added more than once") + } + seenSongs[int64(id)] = struct{}{} + + songs = append(songs, domain.EditAlbumSong{ + SongID: id, + // Track numbers should start at 1 + TrackNumber: i + 1, + }) + } + + album = domain.EditAlbum{ + Title: form.Title, + LatinTitle: latinTitle, + ReleaseDate: releaseDate, + Artists: artists, + Songs: songs, + } + + return album, nil +} + +func (ss *ServerState) EditAlbum(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "EditAlbum") + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + logger.Error("failed to convert album id to integer", "id", idStr, "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + slug := r.PathValue("slug") + logger = logger.With("id", id, "slug", slug) + + switch r.Method { + case http.MethodGet: + album, err := db.Album(ss.db, domain.AlbumID(id)) + if err != nil { + logger.Error("failed to get album", "err", err) + htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for album '%s'", slug)) + return + } + + err = editalbum.EditAlbum(editalbum.EditAlbumParams{ + Album: album, + }).Render(r.Context(), w) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render edit album page", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + case http.MethodPost: + err := r.ParseForm() + if err != nil { + logger.Error("failed to parse http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, "Failed to edit album.") + return + } + + var editAlbumForm EditAlbumForm + err = decoder.Decode(&editAlbumForm, r.PostForm) + if err != nil { + logger.Error("failed to decode http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + updatedAlbum, err := parseEditAlbumForm(editAlbumForm) + if err != nil { + logger.Error("failed to parse edited album form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + updatedAlbum.ID = domain.AlbumID(id) + + tx, err := ss.db.BeginTx(r.Context(), nil) + if err != nil { + logger.Error("failed to begin db transaction", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to edit album.") + return + } + + err = db.UpdateAlbum(tx, updatedAlbum) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } + logger.Error("failed to save edited album into db", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to edit album.") + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit db transaction", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to edit album.") + return + } + + logger.Info("edited album", "album", updatedAlbum) + + htmx.HxRedirect(w, http.StatusOK, "/") + } +} diff --git a/internal/server/router.go b/internal/server/router.go @@ -25,6 +25,8 @@ func (ss *ServerState) Router() http.Handler { router.Route("/album", func(router chi.Router) { router.Route("/{id}", func(router chi.Router) { router.Get("/{slug}", ss.Album) + router.Get("/{slug}/edit", ss.EditAlbum) + router.Post("/{slug}/edit", ss.EditAlbum) }) router.Get("/new", ss.NewAlbum) router.Post("/new", ss.NewAlbum) diff --git a/internal/server/routes/routes.go b/internal/server/routes/routes.go @@ -27,6 +27,10 @@ func Album(album domain.Album) string { return AlbumPath(album.ID, title) } +func AlbumEdit(album domain.Album) string { + return fmt.Sprintf("%s/edit", Album(album)) +} + func AlbumPath(id domain.AlbumID, title string) string { return fmt.Sprintf("/album/%d/%s", id, domain.Slug(title)) } diff --git a/internal/server/ui/views/album/album.templ b/internal/server/ui/views/album/album.templ @@ -23,12 +23,12 @@ templ Album(params AlbumParams) { <p class="section-label -muted">No songs yet.</p> } else { <ol> - for _, song := range params.Album.Songs { + for _, as := range params.Album.Songs { <li> - <a href={ routes.Song(song) }> - { song.Title } - if song.LatinTitle != nil { - <span>({ *song.LatinTitle })</span> + <a href={ routes.Song(as.Song) }> + { as.Song.Title } + if as.Song.LatinTitle != nil { + <span>({ *as.Song.LatinTitle })</span> } </a> </li> @@ -36,6 +36,9 @@ templ Album(params AlbumParams) { </ol> } </section> + <footer aria-label="Album actions"> + <a href={ routes.AlbumEdit(params.Album) }>edit album</a> + </footer> </section> <aside class="album-details"> <dl> diff --git a/internal/server/ui/views/artist/artist.templ b/internal/server/ui/views/artist/artist.templ @@ -46,12 +46,12 @@ templ Artist(params ArtistParams) { </header> <section aria-label="Track List"> <ol> - for _, song := range album.Songs { + for _, as := range album.Songs { <li> - <a href={ routes.Song(song) }> - { song.Title } - if song.LatinTitle != nil { - <span>({ *song.LatinTitle })</span> + <a href={ routes.Song(as.Song) }> + { as.Song.Title } + if as.Song.LatinTitle != nil { + <span>({ *as.Song.LatinTitle })</span> } </a> </li> diff --git a/internal/server/ui/views/edit-album/edit-album.go b/internal/server/ui/views/edit-album/edit-album.go @@ -0,0 +1,64 @@ +package editalbum + +import ( + "encoding/json" + "lyrics/internal/domain" +) + +type EditAlbumParams struct { + Album domain.Album +} + +type editAlbumArtist struct { + UID int `json:"uid"` + Selected bool `json:"selected"` + ArtistID domain.ArtistID `json:"artistId"` + ArtistName string `json:"artistName"` +} + +type editAlbumSong struct { + ID domain.SongID `json:"id"` + Title string `json:"title"` +} + +func editAlbumArtistsJSON(artists []domain.AlbumArtist) string { + jsonArtists := make([]editAlbumArtist, 0, len(artists)) + + for i, aa := range artists { + jsonArtists = append(jsonArtists, editAlbumArtist{ + UID: i, + Selected: true, + ArtistID: aa.Artist.ID, + ArtistName: aa.Artist.Name, + }) + } + + bytes, err := json.Marshal(jsonArtists) + if err != nil { + return "[]" + } + + return string(bytes) +} + +func editAlbumSongsJSON(songs []domain.AlbumSong) string { + jsonSongs := make([]editAlbumSong, 0, len(songs)) + + for _, as := range songs { + title := as.Song.Title + if as.Song.LatinTitle != nil { + title += " (" + *as.Song.LatinTitle + ")" + } + jsonSongs = append(jsonSongs, editAlbumSong{ + ID: as.Song.ID, + Title: title, + }) + } + + bytes, err := json.Marshal(jsonSongs) + if err != nil { + return "[]" + } + + return string(bytes) +} diff --git a/internal/server/ui/views/edit-album/edit-album.js b/internal/server/ui/views/edit-album/edit-album.js @@ -0,0 +1,9 @@ +// 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("/album") || + !evt.detail.ctx.request.action.endsWith("/edit") + ) return; + evt.detail.ctx.request.body.delete("artist"); +}); diff --git a/internal/server/ui/views/edit-album/edit-album.templ b/internal/server/ui/views/edit-album/edit-album.templ @@ -0,0 +1,188 @@ +package editalbum + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ EditAlbum(params EditAlbumParams) { + @base.Base(base.BaseParams{Title: "edit " + params.Album.Title}) { + <h1>Edit album</h1> + <form + class="album-form" + hx-post={ routes.AlbumEdit(params.Album) } + hx-swap="none" + hx-disable-elt="#save-album-btn" + > + <fieldset + x-data="{ showLatinTitle: false }" + x-init="showLatinTitle = isNonAscii($refs.albumTitle.value)" + > + <legend>Album</legend> + <label> + title: + <input + x-ref="albumTitle" + name="album" + type="text" + value={ params.Album.Title } + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + required + @input="showLatinTitle = isNonAscii($event.target.value)" + /> + </label> + <label x-show="showLatinTitle"> + latin title: + <input + name="latin-title" + type="text" + if params.Album.LatinTitle != nil { + value={ *params.Album.LatinTitle } + } + :required="showLatinTitle" + pattern={ domain.IsValidSortNamePattern } + /> + </label> + </fieldset> + <fieldset + x-data={ fmt.Sprintf("{ items: %s, uid: %d }", editAlbumArtistsJSON(params.Album.Artists), len(params.Album.Artists)) } + > + <legend>Artists</legend> + <ol class="artist-picker-list"> + <template x-for="(item, i) in items" :key="item.uid"> + <li + x-data="item" + x-init="htmx.process($el); initLucideIcons()" + > + <div class="artist-picker-row"> + <div class="artist-picker-main"> + <label x-show="!item.selected"> + <span>artist:</span> + <input + type="search" + hx-get="/artist/search?format=suggest" + hx-trigger="input changed delay:500ms" + hx-target="next [role='radiogroup']" + hx-swap="outerHTML" + name="artist" + autocomplete="off" + :required="!item.selected" + /> + </label> + <div class="selected-picker-item" x-show="item.selected"> + <strong x-text="item.artistName"></strong> + <button + type="button" + class="-secondary" + @click="item.selected = false; item.artistId = ''; item.artistName = ''" + > + change + </button> + </div> + </div> + <div class="artist-picker-actions"> + <button + type="button" + aria-label="Remove" + x-show="items.length > 1" + @click="items.splice(i, 1)" + > + <i data-lucide="x"></i> + </button> + <button + type="button" + aria-label="Move up" + x-show="item.selected && items.length > 1 && i >= 1" + @click="[items[i - 1], items[i]] = [items[i], items[i - 1]]" + > + <i data-lucide="chevron-up"></i> + </button> + <button + type="button" + aria-label="Move down" + x-show="item.selected && items.length > 1 && i + 1 < items.length" + @click="[items[i + 1], items[i]] = [items[i], items[i + 1]]" + > + <i data-lucide="chevron-down"></i> + </button> + </div> + </div> + <div x-show="!item.selected"> + <fieldset role="radiogroup" x-show="false"></fieldset> + </div> + <input type="hidden" :name="item.selected ? 'artist-id' : ''" :value="item.artistId"/> + </li> + </template> + </ol> + <button + class="-secondary" + type="button" + @click="items.push({ uid: uid++, selected: false, artistId: '', artistName: '' })" + > + add additional artist + </button> + </fieldset> + <fieldset> + <legend>Release</legend> + <label> + release date: + <input + name="release-date" + type="date" + value={ params.Album.ReleaseDate.Format(time.DateOnly) } + min="1000-01-01" + max={ fmt.Sprintf("%s", time.Now().AddDate(3, 0, 0).Format("2006-01-02")) } + required + /> + </label> + </fieldset> + <fieldset + x-data={ fmt.Sprintf("{ items: %s }", editAlbumSongsJSON(params.Album.Songs)) } + > + <legend>Songs</legend> + if len(params.Album.Songs) == 0 { + <p class="section-label -muted">No songs yet.</p> + } else { + <ol class="artist-picker-list"> + <template x-for="(item, i) in items" :key="item.id"> + <li x-init="htmx.process($el); initLucideIcons()"> + <div class="artist-picker-row"> + <div class="artist-picker-main"> + <strong x-text="item.title"></strong> + </div> + <div class="artist-picker-actions"> + <button + type="button" + aria-label="Move up" + x-show="i >= 1" + @click="[items[i - 1], items[i]] = [items[i], items[i - 1]]" + > + <i data-lucide="chevron-up"></i> + </button> + <button + type="button" + aria-label="Move down" + x-show="i + 1 < items.length" + @click="[items[i + 1], items[i]] = [items[i], items[i + 1]]" + > + <i data-lucide="chevron-down"></i> + </button> + </div> + </div> + <input type="hidden" name="song-id" :value="item.id"/> + </li> + </template> + </ol> + } + </fieldset> + <footer aria-label="Form actions"> + <button class="-primary" id="save-album-btn" type="submit">save changes</button> + <a href={ routes.Album(params.Album) }>cancel</a> + </footer> + </form> + <script src="/static/js/edit-album.js" defer></script> + } +} diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh @@ -24,4 +24,5 @@ minify internal/server/ui/js/string-utils.js -o static/files/js/string-utils.js minify internal/server/ui/js/lucide-utils.js -o static/files/js/lucide-utils.js minify internal/server/ui/views/new-song/new-song.js -o static/files/js/new-song.js minify internal/server/ui/views/new-album/new-album.js -o static/files/js/new-album.js +minify internal/server/ui/views/edit-album/edit-album.js -o static/files/js/edit-album.js minify internal/server/toast/toast.js -o static/files/js/toast.js