lyrics

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

commit e5f4ff39a63baf26b1c2418fae2eab9871378a87
parent cc3450b151f86ab8c167b2c5fa49dcc67110b0f0
Author: brookjeynes <me@brookjeynes.dev>
Date:   Tue, 16 Jun 2026 09:14:54 +1000

refactor: rename album to release

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

Diffstat:
Dinternal/db/album.go | 306-------------------------------------------------------------------------------
Minternal/db/artist.go | 42+++++++++++++++++++++---------------------
Minternal/db/db.go | 28++++++++++++++--------------
Ainternal/db/release.go | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/db/song.go | 78+++++++++++++++++++++++++++++++++++++++---------------------------------------
Dinternal/domain/album.go | 56--------------------------------------------------------
Ainternal/domain/release.go | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/domain/song.go | 12++++++------
Dinternal/server/album-cover.go | 79-------------------------------------------------------------------------------
Dinternal/server/album.go | 59-----------------------------------------------------------
Minternal/server/artist.go | 4++--
Dinternal/server/edit-album.go | 206-------------------------------------------------------------------------------
Ainternal/server/edit-release.go | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/server/new-album.go | 164-------------------------------------------------------------------------------
Ainternal/server/new-release.go | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/new-song.go | 24++++++++++++------------
Ainternal/server/release-cover.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/release.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/router.go | 16++++++++--------
Minternal/server/routes/routes.go | 22+++++++++++-----------
Dinternal/server/search-album.go | 57---------------------------------------------------------
Ainternal/server/search-release.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/ui/layouts/base/base.go | 2+-
Minternal/server/ui/layouts/base/base.templ | 2+-
Dinternal/server/ui/partials/album-cover/album-cover.go | 21---------------------
Dinternal/server/ui/partials/album-cover/album-cover.templ | 18------------------
Dinternal/server/ui/partials/album-search-results/album-search-results.go | 7-------
Dinternal/server/ui/partials/album-search-results/album-search-results.templ | 35-----------------------------------
Ainternal/server/ui/partials/release-cover/release-cover.go | 21+++++++++++++++++++++
Ainternal/server/ui/partials/release-cover/release-cover.templ | 18++++++++++++++++++
Ainternal/server/ui/partials/release-search-results/release-search-results.go | 7+++++++
Ainternal/server/ui/partials/release-search-results/release-search-results.templ | 35+++++++++++++++++++++++++++++++++++
Dinternal/server/ui/partials/similar-albums-list/similar-albums-list.go | 7-------
Dinternal/server/ui/partials/similar-albums-list/similar-albums-list.templ | 23-----------------------
Ainternal/server/ui/partials/similar-releases-list/similar-releases-list.go | 7+++++++
Ainternal/server/ui/partials/similar-releases-list/similar-releases-list.templ | 23+++++++++++++++++++++++
Dinternal/server/ui/views/album/album.css | 25-------------------------
Dinternal/server/ui/views/album/album.go | 7-------
Dinternal/server/ui/views/album/album.templ | 86-------------------------------------------------------------------------------
Dinternal/server/ui/views/artist/artist.css | 5-----
Minternal/server/ui/views/artist/artist.go | 2+-
Minternal/server/ui/views/artist/artist.templ | 20++++++++++----------
Dinternal/server/ui/views/edit-album/edit-album.go | 64----------------------------------------------------------------
Dinternal/server/ui/views/edit-album/edit-album.js | 9---------
Dinternal/server/ui/views/edit-album/edit-album.templ | 204-------------------------------------------------------------------------------
Ainternal/server/ui/views/edit-release/edit-release.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/ui/views/edit-release/edit-release.js | 9+++++++++
Ainternal/server/ui/views/edit-release/edit-release.templ | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/ui/views/index/index.templ | 10+++++-----
Dinternal/server/ui/views/new-album/new-album.css | 15---------------
Dinternal/server/ui/views/new-album/new-album.go | 3---
Dinternal/server/ui/views/new-album/new-album.js | 6------
Dinternal/server/ui/views/new-album/new-album.templ | 150-------------------------------------------------------------------------------
Ainternal/server/ui/views/new-release/new-release.css | 15+++++++++++++++
Ainternal/server/ui/views/new-release/new-release.go | 3+++
Ainternal/server/ui/views/new-release/new-release.js | 6++++++
Ainternal/server/ui/views/new-release/new-release.templ | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/ui/views/new-song/new-song.js | 2+-
Minternal/server/ui/views/new-song/new-song.templ | 18+++++++++---------
Ainternal/server/ui/views/release/release.css | 25+++++++++++++++++++++++++
Ainternal/server/ui/views/release/release.go | 7+++++++
Ainternal/server/ui/views/release/release.templ | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/ui/views/song/song.templ | 12++++++------
Mscripts/build-assets.sh | 9++++-----
Atasks/add_release_types.txt | 10++++++++++
Mtasks/done/style_and_improve_ux_for_new_song_form.txt | 3++-
Dtasks/style_and_improve_ux_for_new_song_form.txt | 4----
67 files changed, 1770 insertions(+), 1769 deletions(-)

diff --git a/internal/db/album.go b/internal/db/album.go @@ -1,306 +0,0 @@ -package db - -import ( - "fmt" - "lyrics/internal/domain" - "strings" - "time" -) - -func Album(e Execer, id domain.AlbumID) (domain.Album, error) { - var album domain.Album - var releaseDateStr string - - err := e.QueryRow(` - select id, musicbrainz_release_id, title, latin_title, release_date - from albums - where id = ? - `, id).Scan(&album.ID, &album.MusicBrainzReleaseID, &album.Title, &album.LatinTitle, &releaseDateStr) - if err != nil { - return domain.Album{}, fmt.Errorf("failed to get album: %w", err) - } - - album.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr) - if err != nil { - return domain.Album{}, fmt.Errorf("failed to parse album release_date: %w", err) - } - - // fetch album_artists rows - type albumArtistRow struct { - artistID domain.ArtistID - role domain.ArtistRole - sortOrder int - } - var artistRows []albumArtistRow - - { - rows, err := e.Query(` - select artist_id, role, sort_order - from album_artists - where album_id = ? - order by sort_order - `, id) - if err != nil { - return domain.Album{}, fmt.Errorf("failed to query album artists: %w", err) - } - - for rows.Next() { - var r albumArtistRow - if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil { - _ = rows.Close() - return domain.Album{}, fmt.Errorf("failed to scan album artist: %w", err) - } - artistRows = append(artistRows, r) - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return domain.Album{}, fmt.Errorf("failed to iterate album artists: %w", err) - } - _ = rows.Close() - } - - // fetch full artist records in one query - if len(artistRows) > 0 { - artistIDs := make([]any, len(artistRows)) - for i, r := range artistRows { - artistIDs[i] = r.artistID - } - placeholders := strings.Repeat("?,", len(artistIDs)) - placeholders = placeholders[:len(placeholders)-1] - - artistMap := make(map[domain.ArtistID]domain.Artist, len(artistIDs)) - { - rows, err := e.Query(` - select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio - from artists - where id in (`+placeholders+`) - `, artistIDs...) - if err != nil { - return domain.Album{}, fmt.Errorf("failed to query artists: %w", err) - } - - for rows.Next() { - var a domain.Artist - if err := rows.Scan(&a.ID, &a.MusicBrainzArtistID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil { - _ = rows.Close() - return domain.Album{}, fmt.Errorf("failed to scan artist: %w", err) - } - artistMap[a.ID] = a - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return domain.Album{}, fmt.Errorf("failed to iterate artists: %w", err) - } - _ = rows.Close() - } - - album.Artists = make([]domain.AlbumArtist, len(artistRows)) - for i, r := range artistRows { - album.Artists[i] = domain.AlbumArtist{ - Artist: artistMap[r.artistID], - Role: r.role, - SortOrder: r.sortOrder, - } - } - } - - // fetch songs on this album ordered by track_number - type albumSongRow struct { - songID domain.SongID - trackNumber int - } - var songRows []albumSongRow - { - rows, err := e.Query(` - select song_id, track_number - from song_albums - where album_id = ? - order by track_number - `, id) - if err != nil { - return domain.Album{}, fmt.Errorf("failed to query album songs: %w", err) - } - - for rows.Next() { - var r albumSongRow - if err := rows.Scan(&r.songID, &r.trackNumber); err != nil { - _ = rows.Close() - return domain.Album{}, fmt.Errorf("failed to scan album song: %w", err) - } - songRows = append(songRows, r) - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return domain.Album{}, fmt.Errorf("failed to iterate album songs: %w", err) - } - _ = rows.Close() - } - - 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, 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 - musicbrainz_release_id = ?, - title = ?, - latin_title = ?, - release_date = ? - where id = ? - `, album.MusicBrainzReleaseID, 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 ( - musicbrainz_release_id, - title, - latin_title, - release_date, - created_at - ) - values (?, ?, ?, ?, ?) - `, album.MusicBrainzReleaseID, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339)) - if err != nil { - return fmt.Errorf("failed to insert album: %w", err) - } - - lastID, err := result.LastInsertId() - if err != nil { - return fmt.Errorf("failed to retrieve last inserted id: %w", err) - } - - for _, artist := range album.Artists { - _, err = e.Exec(` - insert into album_artists ( - album_id, - artist_id, - role, - sort_order - ) - values (?, ?, ?, ?) - on conflict(album_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 album artist: %w", err) - } - } - - return nil -} - -func SearchAlbum(e Execer, albumName string) ([]domain.Album, error) { - albumName = strings.TrimSpace(albumName) - if albumName == "" { - return nil, nil - } - - wildcard := "%" + albumName + "%" - rows, err := e.Query(` - select id, musicbrainz_release_id, title, latin_title, release_date - from albums - where title like ? collate nocase - or latin_title like ? collate nocase - order by title - limit 20 - `, wildcard, wildcard) - if err != nil { - return nil, fmt.Errorf("failed to query albums: %w", err) - } - defer func() { - _ = rows.Close() - }() - - var results []domain.Album - for rows.Next() { - var album domain.Album - var releaseDateStr string - - if err := rows.Scan(&album.ID, &album.MusicBrainzReleaseID, &album.Title, &album.LatinTitle, &releaseDateStr); err != nil { - return nil, fmt.Errorf("failed to scan album row: %w", err) - } - - releaseDate, err := time.Parse(time.RFC3339, releaseDateStr) - if err != nil { - return nil, fmt.Errorf("failed to parse release date string '%s': %w", releaseDateStr, err) - } - - album.ReleaseDate = releaseDate - results = append(results, album) - } - - return results, rows.Err() -} diff --git a/internal/db/artist.go b/internal/db/artist.go @@ -7,7 +7,7 @@ import ( "time" ) -func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []domain.Song, []domain.Song, error) { +func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Release, []domain.Song, []domain.Song, error) { var artist domain.Artist err := e.QueryRow(` @@ -19,54 +19,54 @@ func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []doma return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get artist: %w", err) } - var albumIDs []domain.AlbumID + var releaseIDs []domain.ReleaseID { - // fetch albums where artist is primary, ordered by release_date + // fetch releases where artist is primary, ordered by release_date rows, err := e.Query(` - select album_id - from album_artists + select release_id + from release_artists where artist_id = ? and role = ? - order by (select release_date from albums where id = album_id) + order by (select release_date from releases where id = release_id) `, id, domain.RolePrimary) if err != nil { - return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to query artist albums: %w", err) + return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to query artist releases: %w", err) } for rows.Next() { - var albumID domain.AlbumID - if err := rows.Scan(&albumID); err != nil { + var releaseID domain.ReleaseID + if err := rows.Scan(&releaseID); err != nil { _ = rows.Close() - return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to scan album id: %w", err) + return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to scan release id: %w", err) } - albumIDs = append(albumIDs, albumID) + releaseIDs = append(releaseIDs, releaseID) } if err := rows.Err(); err != nil { _ = rows.Close() - return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to iterate artist albums: %w", err) + return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to iterate artist releases: %w", err) } _ = rows.Close() } - albums := make([]domain.Album, 0, len(albumIDs)) - for _, albumID := range albumIDs { - album, err := Album(e, albumID) + releases := make([]domain.Release, 0, len(releaseIDs)) + for _, releaseID := range releaseIDs { + release, err := Release(e, releaseID) if err != nil { - return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get album %d: %w", albumID, err) + return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get release %d: %w", releaseID, err) } - albums = append(albums, album) + releases = append(releases, release) } var singleIDs []domain.SongID { - // fetch primary singles (primary role, no album) ordered by release_date + // fetch primary singles (primary role, no release) ordered by release_date rows, err := e.Query(` select sa.song_id from song_artists sa join songs s on s.id = sa.song_id where sa.artist_id = ? and sa.role = ? - and not exists (select 1 from song_albums where song_id = sa.song_id) + and not exists (select 1 from song_releases where song_id = sa.song_id) order by s.release_date `, id, domain.RolePrimary) if err != nil { @@ -99,7 +99,7 @@ func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []doma var featuredIDs []domain.SongID { - // fetch featured songs (featured role, regardless of album) ordered by release_date + // fetch featured songs (featured role, regardless of release) ordered by release_date rows, err := e.Query(` select sa.song_id from song_artists sa @@ -136,7 +136,7 @@ func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []doma featured = append(featured, song) } - return artist, albums, singles, featured, nil + return artist, releases, singles, featured, nil } func InsertArtist(e Execer, artist domain.Artist) error { diff --git a/internal/db/db.go b/internal/db/db.go @@ -89,7 +89,7 @@ func New(ctx context.Context, dbPath string) (*DB, error) { } _, err = tx.ExecContext(ctx, ` - create table if not exists albums ( + create table if not exists releases ( id integer primary key autoincrement, musicbrainz_release_id text, title text not null, @@ -101,24 +101,24 @@ func New(ctx context.Context, dbPath string) (*DB, error) { ); `) if err != nil { - return nil, fmt.Errorf("failed to create albums table: %w", err) + return nil, fmt.Errorf("failed to create releases table: %w", err) } _, err = tx.ExecContext(ctx, ` - create table if not exists album_artists ( - album_id integer not null, + create table if not exists release_artists ( + release_id integer not null, artist_id integer not null, role text not null, sort_order integer not null, - unique(album_id, role, sort_order), - foreign key (album_id) references albums(id) on delete cascade, + unique(release_id, role, sort_order), + foreign key (release_id) references releases(id) on delete cascade, foreign key (artist_id) references artists(id) on delete cascade, - primary key (album_id, artist_id, role) + primary key (release_id, artist_id, role) ); `) if err != nil { - return nil, fmt.Errorf("failed to create album_artists table: %w", err) + return nil, fmt.Errorf("failed to create release_artists table: %w", err) } _, err = tx.ExecContext(ctx, ` @@ -152,19 +152,19 @@ func New(ctx context.Context, dbPath string) (*DB, error) { } _, err = tx.ExecContext(ctx, ` - create table if not exists song_albums ( + create table if not exists song_releases ( song_id integer not null, - album_id integer not null, + release_id integer not null, track_number integer not null, - unique(album_id, track_number), + unique(release_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) + foreign key (release_id) references releases(id) on delete cascade, + primary key (song_id, release_id) ); `) if err != nil { - return nil, fmt.Errorf("failed to create song_albums table: %w", err) + return nil, fmt.Errorf("failed to create song_releases table: %w", err) } if err = tx.Commit(); err != nil { diff --git a/internal/db/release.go b/internal/db/release.go @@ -0,0 +1,306 @@ +package db + +import ( + "fmt" + "lyrics/internal/domain" + "strings" + "time" +) + +func Release(e Execer, id domain.ReleaseID) (domain.Release, error) { + var release domain.Release + var releaseDateStr string + + err := e.QueryRow(` + select id, musicbrainz_release_id, title, latin_title, release_date + from releases + where id = ? + `, id).Scan(&release.ID, &release.MusicBrainzReleaseID, &release.Title, &release.LatinTitle, &releaseDateStr) + if err != nil { + return domain.Release{}, fmt.Errorf("failed to get release: %w", err) + } + + release.ReleaseDate, err = time.Parse(time.RFC3339, releaseDateStr) + if err != nil { + return domain.Release{}, fmt.Errorf("failed to parse release release_date: %w", err) + } + + // fetch release_artists rows + type releaseArtistRow struct { + artistID domain.ArtistID + role domain.ArtistRole + sortOrder int + } + var artistRows []releaseArtistRow + + { + rows, err := e.Query(` + select artist_id, role, sort_order + from release_artists + where release_id = ? + order by sort_order + `, id) + if err != nil { + return domain.Release{}, fmt.Errorf("failed to query release artists: %w", err) + } + + for rows.Next() { + var r releaseArtistRow + if err := rows.Scan(&r.artistID, &r.role, &r.sortOrder); err != nil { + _ = rows.Close() + return domain.Release{}, fmt.Errorf("failed to scan release artist: %w", err) + } + artistRows = append(artistRows, r) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return domain.Release{}, fmt.Errorf("failed to iterate release artists: %w", err) + } + _ = rows.Close() + } + + // fetch full artist records in one query + if len(artistRows) > 0 { + artistIDs := make([]any, len(artistRows)) + for i, r := range artistRows { + artistIDs[i] = r.artistID + } + placeholders := strings.Repeat("?,", len(artistIDs)) + placeholders = placeholders[:len(placeholders)-1] + + artistMap := make(map[domain.ArtistID]domain.Artist, len(artistIDs)) + { + rows, err := e.Query(` + select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio + from artists + where id in (`+placeholders+`) + `, artistIDs...) + if err != nil { + return domain.Release{}, fmt.Errorf("failed to query artists: %w", err) + } + + for rows.Next() { + var a domain.Artist + if err := rows.Scan(&a.ID, &a.MusicBrainzArtistID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil { + _ = rows.Close() + return domain.Release{}, fmt.Errorf("failed to scan artist: %w", err) + } + artistMap[a.ID] = a + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return domain.Release{}, fmt.Errorf("failed to iterate artists: %w", err) + } + _ = rows.Close() + } + + release.Artists = make([]domain.ReleaseArtist, len(artistRows)) + for i, r := range artistRows { + release.Artists[i] = domain.ReleaseArtist{ + Artist: artistMap[r.artistID], + Role: r.role, + SortOrder: r.sortOrder, + } + } + } + + // fetch songs on this release ordered by track_number + type releaseSongRow struct { + songID domain.SongID + trackNumber int + } + var songRows []releaseSongRow + { + rows, err := e.Query(` + select song_id, track_number + from song_releases + where release_id = ? + order by track_number + `, id) + if err != nil { + return domain.Release{}, fmt.Errorf("failed to query release songs: %w", err) + } + + for rows.Next() { + var r releaseSongRow + if err := rows.Scan(&r.songID, &r.trackNumber); err != nil { + _ = rows.Close() + return domain.Release{}, fmt.Errorf("failed to scan release song: %w", err) + } + songRows = append(songRows, r) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return domain.Release{}, fmt.Errorf("failed to iterate release songs: %w", err) + } + _ = rows.Close() + } + + release.Songs = make([]domain.ReleaseSong, 0, len(songRows)) + for _, r := range songRows { + song, err := Song(e, r.songID) + if err != nil { + return domain.Release{}, fmt.Errorf("failed to get song %d: %w", r.songID, err) + } + release.Songs = append(release.Songs, domain.ReleaseSong{ + Song: song, + TrackNumber: r.trackNumber, + }) + } + + return release, nil +} + +func UpdateRelease(e Execer, release domain.EditRelease) error { + result, err := e.Exec(` + update releases + set + musicbrainz_release_id = ?, + title = ?, + latin_title = ?, + release_date = ? + where id = ? + `, release.MusicBrainzReleaseID, release.Title, release.LatinTitle, release.ReleaseDate.Format(time.RFC3339), release.ID) + if err != nil { + return fmt.Errorf("failed to update release: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check updated release rows: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("release %d does not exist", release.ID) + } + + _, err = e.Exec(` + delete from release_artists + where release_id = ? + and role = ? + `, release.ID, domain.RolePrimary) + if err != nil { + return fmt.Errorf("failed to delete release primary artists: %w", err) + } + + for _, artist := range release.Artists { + _, err = e.Exec(` + insert into release_artists ( + release_id, + artist_id, + role, + sort_order + ) + values (?, ?, ?, ?) + `, release.ID, artist.ArtistID, artist.Role, artist.SortOrder) + if err != nil { + return fmt.Errorf("failed to insert release artist: %w", err) + } + } + + _, err = e.Exec(` + delete from song_releases + where release_id = ? + `, release.ID) + if err != nil { + return fmt.Errorf("failed to delete release songs: %w", err) + } + + for _, song := range release.Songs { + _, err = e.Exec(` + insert into song_releases ( + song_id, + release_id, + track_number + ) + values (?, ?, ?) + `, song.SongID, release.ID, song.TrackNumber) + if err != nil { + return fmt.Errorf("failed to update release song: %w", err) + } + } + + return nil +} + +func InsertRelease(e Execer, release domain.NewRelease) error { + result, err := e.Exec(` + insert into releases ( + musicbrainz_release_id, + title, + latin_title, + release_date, + created_at + ) + values (?, ?, ?, ?, ?) + `, release.MusicBrainzReleaseID, release.Title, release.LatinTitle, release.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339)) + if err != nil { + return fmt.Errorf("failed to insert release: %w", err) + } + + lastID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to retrieve last inserted id: %w", err) + } + + for _, artist := range release.Artists { + _, err = e.Exec(` + insert into release_artists ( + release_id, + artist_id, + role, + sort_order + ) + values (?, ?, ?, ?) + on conflict(release_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 release artist: %w", err) + } + } + + return nil +} + +func SearchRelease(e Execer, releaseName string) ([]domain.Release, error) { + releaseName = strings.TrimSpace(releaseName) + if releaseName == "" { + return nil, nil + } + + wildcard := "%" + releaseName + "%" + rows, err := e.Query(` + select id, musicbrainz_release_id, title, latin_title, release_date + from releases + where title like ? collate nocase + or latin_title like ? collate nocase + order by title + limit 20 + `, wildcard, wildcard) + if err != nil { + return nil, fmt.Errorf("failed to query releases: %w", err) + } + defer func() { + _ = rows.Close() + }() + + var results []domain.Release + for rows.Next() { + var release domain.Release + var releaseDateStr string + + if err := rows.Scan(&release.ID, &release.MusicBrainzReleaseID, &release.Title, &release.LatinTitle, &releaseDateStr); err != nil { + return nil, fmt.Errorf("failed to scan release row: %w", err) + } + + releaseDate, err := time.Parse(time.RFC3339, releaseDateStr) + if err != nil { + return nil, fmt.Errorf("failed to parse release date string '%s': %w", releaseDateStr, err) + } + + release.ReleaseDate = releaseDate + results = append(results, release) + } + + return results, rows.Err() +} diff --git a/internal/db/song.go b/internal/db/song.go @@ -78,41 +78,41 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) { a.latin_title, a.release_date, sa.track_number - from song_albums sa - join albums a on a.id = sa.album_id + from song_releases sa + join releases a on a.id = sa.release_id where sa.song_id = ? order by sa.track_number `, id) if err != nil { - return domain.Song{}, fmt.Errorf("failed to query song albums: %w", err) + return domain.Song{}, fmt.Errorf("failed to query song releases: %w", err) } for rows.Next() { - var album domain.SongAlbum - var albumReleaseDateStr string + var release domain.SongRelease + var releaseReleaseDateStr string if err := rows.Scan( - &album.Album.ID, - &album.Album.MusicBrainzReleaseID, - &album.Album.Title, - &album.Album.LatinTitle, - &albumReleaseDateStr, - &album.TrackNumber, + &release.Release.ID, + &release.Release.MusicBrainzReleaseID, + &release.Release.Title, + &release.Release.LatinTitle, + &releaseReleaseDateStr, + &release.TrackNumber, ); err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to scan song album: %w", err) + return domain.Song{}, fmt.Errorf("failed to scan song release: %w", err) } - album.Album.ReleaseDate, err = time.Parse(time.RFC3339, albumReleaseDateStr) + release.Release.ReleaseDate, err = time.Parse(time.RFC3339, releaseReleaseDateStr) if err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to parse album release_date: %w", err) + return domain.Song{}, fmt.Errorf("failed to parse release release_date: %w", err) } - song.Albums = append(song.Albums, album) + song.Releases = append(song.Releases, release) } if err := rows.Err(); err != nil { _ = rows.Close() - return domain.Song{}, fmt.Errorf("failed to iterate song albums: %w", err) + return domain.Song{}, fmt.Errorf("failed to iterate song releases: %w", err) } _ = rows.Close() } @@ -254,45 +254,45 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) { a.latin_title, a.release_date, sa.track_number - from song_albums sa - join albums a on a.id = sa.album_id + from song_releases sa + join releases a on a.id = sa.release_id where sa.song_id in (`+placeholders+`) order by sa.song_id, sa.track_number `, songIDs...) if err != nil { - return nil, fmt.Errorf("failed to query recent song albums: %w", err) + return nil, fmt.Errorf("failed to query recent song releases: %w", err) } for rows.Next() { var songID domain.SongID - var album domain.SongAlbum - var albumReleaseDateStr string + var release domain.SongRelease + var releaseReleaseDateStr string if err := rows.Scan( &songID, - &album.Album.ID, - &album.Album.MusicBrainzReleaseID, - &album.Album.Title, - &album.Album.LatinTitle, - &albumReleaseDateStr, - &album.TrackNumber, + &release.Release.ID, + &release.Release.MusicBrainzReleaseID, + &release.Release.Title, + &release.Release.LatinTitle, + &releaseReleaseDateStr, + &release.TrackNumber, ); err != nil { _ = rows.Close() - return nil, fmt.Errorf("failed to scan recent song album: %w", err) + return nil, fmt.Errorf("failed to scan recent song release: %w", err) } - album.Album.ReleaseDate, err = time.Parse(time.RFC3339, albumReleaseDateStr) + release.Release.ReleaseDate, err = time.Parse(time.RFC3339, releaseReleaseDateStr) if err != nil { _ = rows.Close() - return nil, fmt.Errorf("failed to parse recent album release_date: %w", err) + return nil, fmt.Errorf("failed to parse recent release release_date: %w", err) } if song := songByID[songID]; song != nil { - song.Albums = append(song.Albums, album) + song.Releases = append(song.Releases, release) } } if err := rows.Err(); err != nil { _ = rows.Close() - return nil, fmt.Errorf("failed to iterate recent song albums: %w", err) + return nil, fmt.Errorf("failed to iterate recent song releases: %w", err) } _ = rows.Close() } @@ -338,11 +338,11 @@ func InsertSong(e Execer, song domain.NewSong) error { } } - for _, album := range song.Albums { + for _, release := range song.Releases { _, err = e.Exec(` - insert into song_albums ( + insert into song_releases ( song_id, - album_id, + release_id, track_number ) values ( @@ -350,13 +350,13 @@ func InsertSong(e Execer, song domain.NewSong) error { ?, coalesce(( select max(track_number) - from song_albums - where album_id = ? + from song_releases + where release_id = ? ), 0) + 1 ) - `, lastID, album.AlbumID, album.AlbumID) + `, lastID, release.ReleaseID, release.ReleaseID) if err != nil { - return fmt.Errorf("failed to insert song album: %w", err) + return fmt.Errorf("failed to insert song release: %w", err) } } diff --git a/internal/domain/album.go b/internal/domain/album.go @@ -1,56 +0,0 @@ -package domain - -import "time" - -type AlbumID int64 - -type NewAlbum struct { - ID AlbumID - MusicBrainzReleaseID *MusicBrainzID - Title string - LatinTitle *string - ReleaseDate time.Time - Artists []NewAlbumArtist -} - -type NewAlbumArtist struct { - ArtistID ArtistID - Role ArtistRole - SortOrder int -} - -type EditAlbum struct { - ID AlbumID - MusicBrainzReleaseID *MusicBrainzID - Title string - LatinTitle *string - ReleaseDate time.Time - Artists []NewAlbumArtist - Songs []EditAlbumSong -} - -type EditAlbumSong struct { - SongID SongID - TrackNumber int -} - -type Album struct { - ID AlbumID - MusicBrainzReleaseID *MusicBrainzID - Title string - LatinTitle *string - ReleaseDate time.Time - Artists []AlbumArtist - Songs []AlbumSong -} - -type AlbumArtist struct { - Artist Artist - Role ArtistRole - SortOrder int -} - -type AlbumSong struct { - Song Song - TrackNumber int -} diff --git a/internal/domain/release.go b/internal/domain/release.go @@ -0,0 +1,56 @@ +package domain + +import "time" + +type ReleaseID int64 + +type NewRelease struct { + ID ReleaseID + MusicBrainzReleaseID *MusicBrainzID + Title string + LatinTitle *string + ReleaseDate time.Time + Artists []NewReleaseArtist +} + +type NewReleaseArtist struct { + ArtistID ArtistID + Role ArtistRole + SortOrder int +} + +type EditRelease struct { + ID ReleaseID + MusicBrainzReleaseID *MusicBrainzID + Title string + LatinTitle *string + ReleaseDate time.Time + Artists []NewReleaseArtist + Songs []EditReleaseSong +} + +type EditReleaseSong struct { + SongID SongID + TrackNumber int +} + +type Release struct { + ID ReleaseID + MusicBrainzReleaseID *MusicBrainzID + Title string + LatinTitle *string + ReleaseDate time.Time + Artists []ReleaseArtist + Songs []ReleaseSong +} + +type ReleaseArtist struct { + Artist Artist + Role ArtistRole + SortOrder int +} + +type ReleaseSong struct { + Song Song + TrackNumber int +} diff --git a/internal/domain/song.go b/internal/domain/song.go @@ -14,7 +14,7 @@ type NewSong struct { Lyrics string ReleaseDate time.Time Artists []NewSongArtist - Albums []NewSongAlbum + Releases []NewSongRelease Genres []Genre } @@ -24,8 +24,8 @@ type NewSongArtist struct { SortOrder int } -type NewSongAlbum struct { - AlbumID AlbumID +type NewSongRelease struct { + ReleaseID ReleaseID TrackNumber int } @@ -37,7 +37,7 @@ type Song struct { Lyrics string ReleaseDate time.Time Artists []SongArtist - Albums []SongAlbum + Releases []SongRelease Genres []Genre } @@ -47,7 +47,7 @@ type SongArtist struct { SortOrder int } -type SongAlbum struct { - Album Album +type SongRelease struct { + Release Release TrackNumber int } diff --git a/internal/server/album-cover.go b/internal/server/album-cover.go @@ -1,79 +0,0 @@ -package server - -import ( - "context" - "errors" - "fmt" - "lyrics/internal/db" - "lyrics/internal/domain" - "lyrics/internal/external/musicbrainz" - "lyrics/internal/server/htmx" - albumcover "lyrics/internal/server/ui/partials/album-cover" - "net/http" - "strconv" -) - -func (ss *ServerState) AlbumCover(w http.ResponseWriter, r *http.Request) { - logger := ss.logger.With("handler", "AlbumCover") - - 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) - - 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 - } - - albumTitle := album.Title - if album.LatinTitle != nil { - albumTitle = *album.LatinTitle - } - - if domain.Slug(albumTitle) != slug { - w.WriteHeader(http.StatusNotFound) - return - } - - status := albumcover.AlbumCoverStatusNoMBID - var cover *musicbrainz.CoverArt - if album.MusicBrainzReleaseID != nil { - cover, err = musicbrainz.FetchCoverArt( - r.Context(), - string(*album.MusicBrainzReleaseID), - musicbrainz.CoverArtQualitySmall, - ) - if err != nil { - logger.Error("failed to get album music cover", "err", err) - status = albumcover.AlbumCoverStatusError - } else if cover == nil { - status = albumcover.AlbumCoverStatusNotFound - } else { - status = albumcover.AlbumCoverStatusFound - } - } - - err = albumcover.AlbumCover(albumcover.AlbumCoverParams{ - Album: album, - CoverArt: cover, - Status: status, - }).Render(r.Context(), w) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - - logger.Error("failed to render album page", "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } -} diff --git a/internal/server/album.go b/internal/server/album.go @@ -1,59 +0,0 @@ -package server - -import ( - "context" - "errors" - "fmt" - "lyrics/internal/db" - "lyrics/internal/domain" - "lyrics/internal/server/htmx" - "lyrics/internal/server/routes" - albumview "lyrics/internal/server/ui/views/album" - "net/http" - "strconv" -) - -func (ss *ServerState) Album(w http.ResponseWriter, r *http.Request) { - logger := ss.logger.With("handler", "Album") - - 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) - - 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 - } - - albumTitle := album.Title - if album.LatinTitle != nil { - albumTitle = *album.LatinTitle - } - - if domain.Slug(albumTitle) != slug { - htmx.HxRedirect(w, http.StatusMovedPermanently, routes.AlbumPath(domain.AlbumID(id), albumTitle)) - return - } - - err = albumview.Album(albumview.AlbumParams{ - Album: album, - }).Render(r.Context(), w) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - - logger.Error("failed to render album page", "err", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } -} diff --git a/internal/server/artist.go b/internal/server/artist.go @@ -27,7 +27,7 @@ func (ss *ServerState) Artist(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") logger = logger.With("id", id, "slug", slug) - artist, albums, singles, featured, err := db.Artist(ss.db, domain.ArtistID(id)) + artist, releases, singles, featured, err := db.Artist(ss.db, domain.ArtistID(id)) if err != nil { logger.Error("failed to get artist", "err", err) htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for artist '%s'", slug)) @@ -46,7 +46,7 @@ func (ss *ServerState) Artist(w http.ResponseWriter, r *http.Request) { err = artistview.Artist(artistview.ArtistParams{ Artist: artist, - Albums: albums, + Releases: releases, Singles: singles, Featured: featured, }).Render(r.Context(), w) diff --git a/internal/server/edit-album.go b/internal/server/edit-album.go @@ -1,206 +0,0 @@ -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"` - MusicBrainzReleaseID domain.MusicBrainzID `schema:"musicbrainz-release-id"` -} - -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, - }) - } - - // musicbrainz release id - var musicbrainzReleaseID *domain.MusicBrainzID - if form.MusicBrainzReleaseID != "" { - if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzReleaseID)) { - return album, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzReleaseID) - } - musicbrainzReleaseID = &form.MusicBrainzReleaseID - } - - album = domain.EditAlbum{ - MusicBrainzReleaseID: musicbrainzReleaseID, - 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/edit-release.go b/internal/server/edit-release.go @@ -0,0 +1,206 @@ +package server + +import ( + "context" + "errors" + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + editrelease "lyrics/internal/server/ui/views/edit-release" + "net/http" + "strconv" + "time" +) + +type EditReleaseForm struct { + Title string `schema:"release"` + LatinTitle string `schema:"latin-title"` + ArtistID []domain.ArtistID `schema:"artist-id"` + SongID []domain.SongID `schema:"song-id"` + ReleaseDate string `schema:"release-date"` + MusicBrainzReleaseID domain.MusicBrainzID `schema:"musicbrainz-release-id"` +} + +func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { + var release domain.EditRelease + + // title + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { + return release, fmt.Errorf("Title name must not be empty or have leading/trailing whitespace") + } + + // artists + if len(form.ArtistID) == 0 { + return release, fmt.Errorf("At least one artist is required") + } + + artists := make([]domain.NewReleaseArtist, 0, len(form.ArtistID)) + seenArtists := make(map[int64]struct{}, len(form.ArtistID)) + + for i, id := range form.ArtistID { + if id < 0 { + return release, fmt.Errorf("Invalid artist selection") + } + if _, exists := seenArtists[int64(id)]; exists { + return release, fmt.Errorf("An artist cannot be added more than once") + } + seenArtists[int64(id)] = struct{}{} + + artists = append(artists, domain.NewReleaseArtist{ + ArtistID: id, + Role: domain.RolePrimary, + SortOrder: i, + }) + } + + // latin title + var latinTitle *string + if form.LatinTitle != "" { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { + return release, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace") + } + if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { + return release, fmt.Errorf("Latin title contains invalid characters") + } + latinTitle = &form.LatinTitle + } + + releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) + if err != nil { + return release, 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 release, fmt.Errorf("Release date must be between %s and %s", + minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) + } + + songs := make([]domain.EditReleaseSong, 0, len(form.SongID)) + seenSongs := make(map[int64]struct{}, len(form.SongID)) + + for i, id := range form.SongID { + if id < 0 { + return release, fmt.Errorf("Invalid song selection") + } + if _, exists := seenSongs[int64(id)]; exists { + return release, fmt.Errorf("A song cannot be added more than once") + } + seenSongs[int64(id)] = struct{}{} + + songs = append(songs, domain.EditReleaseSong{ + SongID: id, + // Track numbers should start at 1 + TrackNumber: i + 1, + }) + } + + // musicbrainz release id + var musicbrainzReleaseID *domain.MusicBrainzID + if form.MusicBrainzReleaseID != "" { + if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzReleaseID)) { + return release, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzReleaseID) + } + musicbrainzReleaseID = &form.MusicBrainzReleaseID + } + + release = domain.EditRelease{ + MusicBrainzReleaseID: musicbrainzReleaseID, + Title: form.Title, + LatinTitle: latinTitle, + ReleaseDate: releaseDate, + Artists: artists, + Songs: songs, + } + + return release, nil +} + +func (ss *ServerState) EditRelease(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "EditRelease") + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + logger.Error("failed to convert release 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: + release, err := db.Release(ss.db, domain.ReleaseID(id)) + if err != nil { + logger.Error("failed to get release", "err", err) + htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for release '%s'", slug)) + return + } + + err = editrelease.EditRelease(editrelease.EditReleaseParams{ + Release: release, + }).Render(r.Context(), w) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render edit release 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 release.") + return + } + + var editReleaseForm EditReleaseForm + err = decoder.Decode(&editReleaseForm, r.PostForm) + if err != nil { + logger.Error("failed to decode http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + updatedRelease, err := parseEditReleaseForm(editReleaseForm) + if err != nil { + logger.Error("failed to parse edited release form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + updatedRelease.ID = domain.ReleaseID(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 release.") + return + } + + err = db.UpdateRelease(tx, updatedRelease) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } + logger.Error("failed to save edited release into db", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to edit release.") + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit db transaction", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to edit release.") + return + } + + logger.Info("edited release", "release", updatedRelease) + + htmx.HxRedirect(w, http.StatusOK, "/") + } +} diff --git a/internal/server/new-album.go b/internal/server/new-album.go @@ -1,164 +0,0 @@ -package server - -import ( - "context" - "errors" - "fmt" - "lyrics/internal/db" - "lyrics/internal/domain" - "lyrics/internal/server/htmx" - newalbum "lyrics/internal/server/ui/views/new-album" - "net/http" - "time" -) - -type NewAlbumForm struct { - Title string `schema:"album"` - LatinTitle string `schema:"latin-title"` - ArtistID []domain.ArtistID `schema:"artist-id"` - ReleaseDate string `schema:"release-date"` - MusicBrainzReleaseID domain.MusicBrainzID `schema:"musicbrainz-release-id"` -} - -func parseNewAlbumForm(form NewAlbumForm) (domain.NewAlbum, error) { - var album domain.NewAlbum - - // 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 - } - - // release date - 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")) - } - - // musicbrainz release id - var musicbrainzReleaseID *domain.MusicBrainzID - if form.MusicBrainzReleaseID != "" { - if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzReleaseID)) { - return album, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzReleaseID) - } - musicbrainzReleaseID = &form.MusicBrainzReleaseID - } - - album = domain.NewAlbum{ - MusicBrainzReleaseID: musicbrainzReleaseID, - Title: form.Title, - LatinTitle: latinTitle, - ReleaseDate: releaseDate, - Artists: artists, - } - - return album, nil -} - -func (ss *ServerState) NewAlbum(w http.ResponseWriter, r *http.Request) { - logger := ss.logger.With("handler", "NewAlbum") - - switch r.Method { - case http.MethodGet: - err := newalbum.NewAlbum(newalbum.NewAlbumParams{}).Render(r.Context(), w) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - - logger.Error("failed to render new 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 add album.") - return - } - - var newAlbumForm NewAlbumForm - err = decoder.Decode(&newAlbumForm, r.PostForm) - if err != nil { - logger.Error("failed to decode http form", "err", err) - htmx.HxError(w, http.StatusBadRequest, err.Error()) - return - } - - newAlbum, err := parseNewAlbumForm(newAlbumForm) - if err != nil { - logger.Error("failed to parse new album form", "err", err) - htmx.HxError(w, http.StatusBadRequest, err.Error()) - return - } - - 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 add album.") - return - } - - err = db.InsertAlbum(tx, newAlbum) - if err != nil { - if rollbackErr := tx.Rollback(); rollbackErr != nil { - logger.Error("failed to rollback db transaction", "err", rollbackErr) - } - logger.Error("failed to save new album into db", "err", err) - htmx.HxError(w, http.StatusInternalServerError, "Failed to add album.") - return - } - - if err := tx.Commit(); err != nil { - logger.Error("failed to commit db transaction", "err", err) - htmx.HxError(w, http.StatusInternalServerError, "Failed to add album.") - return - } - - logger.Info("added album", "album", newAlbum) - - htmx.HxRedirect(w, http.StatusOK, "/") - } -} diff --git a/internal/server/new-release.go b/internal/server/new-release.go @@ -0,0 +1,164 @@ +package server + +import ( + "context" + "errors" + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + newrelease "lyrics/internal/server/ui/views/new-release" + "net/http" + "time" +) + +type NewReleaseForm struct { + Title string `schema:"release"` + LatinTitle string `schema:"latin-title"` + ArtistID []domain.ArtistID `schema:"artist-id"` + ReleaseDate string `schema:"release-date"` + MusicBrainzReleaseID domain.MusicBrainzID `schema:"musicbrainz-release-id"` +} + +func parseNewReleaseForm(form NewReleaseForm) (domain.NewRelease, error) { + var release domain.NewRelease + + // title + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { + return release, fmt.Errorf("Title name must not be empty or have leading/trailing whitespace") + } + + // artists + if len(form.ArtistID) == 0 { + return release, fmt.Errorf("At least one artist is required") + } + + artists := make([]domain.NewReleaseArtist, 0, len(form.ArtistID)) + seenArtists := make(map[int64]struct{}, len(form.ArtistID)) + + for i, id := range form.ArtistID { + if id < 0 { + return release, fmt.Errorf("Invalid artist selection") + } + if _, exists := seenArtists[int64(id)]; exists { + return release, fmt.Errorf("An artist cannot be added more than once") + } + seenArtists[int64(id)] = struct{}{} + + artists = append(artists, domain.NewReleaseArtist{ + ArtistID: id, + Role: domain.RolePrimary, + SortOrder: i, + }) + } + + // latin title + var latinTitle *string + if form.LatinTitle != "" { + if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { + return release, fmt.Errorf("Latin title must not be empty or have leading/trailing whitespace") + } + if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { + return release, fmt.Errorf("Latin title contains invalid characters") + } + latinTitle = &form.LatinTitle + } + + // release date + releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) + if err != nil { + return release, 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 release, fmt.Errorf("Release date must be between %s and %s", + minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) + } + + // musicbrainz release id + var musicbrainzReleaseID *domain.MusicBrainzID + if form.MusicBrainzReleaseID != "" { + if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzReleaseID)) { + return release, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzReleaseID) + } + musicbrainzReleaseID = &form.MusicBrainzReleaseID + } + + release = domain.NewRelease{ + MusicBrainzReleaseID: musicbrainzReleaseID, + Title: form.Title, + LatinTitle: latinTitle, + ReleaseDate: releaseDate, + Artists: artists, + } + + return release, nil +} + +func (ss *ServerState) NewRelease(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "NewRelease") + + switch r.Method { + case http.MethodGet: + err := newrelease.NewRelease(newrelease.NewReleaseParams{}).Render(r.Context(), w) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render new release 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 add release.") + return + } + + var newReleaseForm NewReleaseForm + err = decoder.Decode(&newReleaseForm, r.PostForm) + if err != nil { + logger.Error("failed to decode http form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + newRelease, err := parseNewReleaseForm(newReleaseForm) + if err != nil { + logger.Error("failed to parse new release form", "err", err) + htmx.HxError(w, http.StatusBadRequest, err.Error()) + return + } + + 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 add release.") + return + } + + err = db.InsertRelease(tx, newRelease) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + logger.Error("failed to rollback db transaction", "err", rollbackErr) + } + logger.Error("failed to save new release into db", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") + return + } + + if err := tx.Commit(); err != nil { + logger.Error("failed to commit db transaction", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") + return + } + + logger.Info("added release", "release", newRelease) + + htmx.HxRedirect(w, http.StatusOK, "/") + } +} diff --git a/internal/server/new-song.go b/internal/server/new-song.go @@ -16,7 +16,7 @@ type NewSongForm struct { ArtistID []domain.ArtistID `schema:"artist-id"` Title string `schema:"title"` LatinTitle string `schema:"latin-title"` - AlbumID []domain.AlbumID `schema:"album-id"` + ReleaseID []domain.ReleaseID `schema:"release-id"` Genres []string `schema:"genres"` Lyrics string `schema:"lyrics"` FeaturingID []domain.ArtistID `schema:"featuring-id"` @@ -98,20 +98,20 @@ func parseNewSongForm(form NewSongForm) (domain.NewSong, error) { genres = append(genres, genre) } - // albums - albums := make([]domain.NewSongAlbum, 0, len(form.AlbumID)) - seenAlbums := make(map[int64]struct{}, len(form.AlbumID)) - for _, id := range form.AlbumID { + // releases + releases := make([]domain.NewSongRelease, 0, len(form.ReleaseID)) + seenReleases := make(map[int64]struct{}, len(form.ReleaseID)) + for _, id := range form.ReleaseID { if id < 0 { - return song, fmt.Errorf("Invalid album selection") + return song, fmt.Errorf("Invalid release selection") } - if _, exists := seenAlbums[int64(id)]; exists { - return song, fmt.Errorf("An album cannot be added more than once") + if _, exists := seenReleases[int64(id)]; exists { + return song, fmt.Errorf("A release cannot be added more than once") } - seenAlbums[int64(id)] = struct{}{} + seenReleases[int64(id)] = struct{}{} - albums = append(albums, domain.NewSongAlbum{ - AlbumID: id, + releases = append(releases, domain.NewSongRelease{ + ReleaseID: id, }) } @@ -142,7 +142,7 @@ func parseNewSongForm(form NewSongForm) (domain.NewSong, error) { Lyrics: form.Lyrics, ReleaseDate: releaseDate, Genres: genres, - Albums: albums, + Releases: releases, Artists: artists, MusicBrainzRecordingID: musicbrainzRecordingID, } diff --git a/internal/server/release-cover.go b/internal/server/release-cover.go @@ -0,0 +1,79 @@ +package server + +import ( + "context" + "errors" + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/external/musicbrainz" + "lyrics/internal/server/htmx" + releasecover "lyrics/internal/server/ui/partials/release-cover" + "net/http" + "strconv" +) + +func (ss *ServerState) ReleaseCover(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "ReleaseCover") + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + logger.Error("failed to convert release 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) + + release, err := db.Release(ss.db, domain.ReleaseID(id)) + if err != nil { + logger.Error("failed to get release", "err", err) + htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for release '%s'", slug)) + return + } + + releaseTitle := release.Title + if release.LatinTitle != nil { + releaseTitle = *release.LatinTitle + } + + if domain.Slug(releaseTitle) != slug { + w.WriteHeader(http.StatusNotFound) + return + } + + status := releasecover.ReleaseCoverStatusNoMBID + var cover *musicbrainz.CoverArt + if release.MusicBrainzReleaseID != nil { + cover, err = musicbrainz.FetchCoverArt( + r.Context(), + string(*release.MusicBrainzReleaseID), + musicbrainz.CoverArtQualitySmall, + ) + if err != nil { + logger.Error("failed to get release cover art", "err", err) + status = releasecover.ReleaseCoverStatusError + } else if cover == nil { + status = releasecover.ReleaseCoverStatusNotFound + } else { + status = releasecover.ReleaseCoverStatusFound + } + } + + err = releasecover.ReleaseCover(releasecover.ReleaseCoverParams{ + Release: release, + CoverArt: cover, + Status: status, + }).Render(r.Context(), w) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render release page", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} diff --git a/internal/server/release.go b/internal/server/release.go @@ -0,0 +1,59 @@ +package server + +import ( + "context" + "errors" + "fmt" + "lyrics/internal/db" + "lyrics/internal/domain" + "lyrics/internal/server/htmx" + "lyrics/internal/server/routes" + releaseview "lyrics/internal/server/ui/views/release" + "net/http" + "strconv" +) + +func (ss *ServerState) Release(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "Release") + + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + logger.Error("failed to convert release 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) + + release, err := db.Release(ss.db, domain.ReleaseID(id)) + if err != nil { + logger.Error("failed to get release", "err", err) + htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for release '%s'", slug)) + return + } + + releaseTitle := release.Title + if release.LatinTitle != nil { + releaseTitle = *release.LatinTitle + } + + if domain.Slug(releaseTitle) != slug { + htmx.HxRedirect(w, http.StatusMovedPermanently, routes.ReleasePath(domain.ReleaseID(id), releaseTitle)) + return + } + + err = releaseview.Release(releaseview.ReleaseParams{ + Release: release, + }).Render(r.Context(), w) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render release page", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} diff --git a/internal/server/router.go b/internal/server/router.go @@ -22,16 +22,16 @@ func (ss *ServerState) Router() http.Handler { router.Post("/new", ss.NewSong) }) - router.Route("/album", func(router chi.Router) { + router.Route("/release", 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("/{slug}/cover", ss.AlbumCover) + router.Get("/{slug}", ss.Release) + router.Get("/{slug}/edit", ss.EditRelease) + router.Post("/{slug}/edit", ss.EditRelease) + router.Get("/{slug}/cover", ss.ReleaseCover) }) - router.Get("/new", ss.NewAlbum) - router.Post("/new", ss.NewAlbum) - router.Get("/search", ss.SearchAlbum) + router.Get("/new", ss.NewRelease) + router.Post("/new", ss.NewRelease) + router.Get("/search", ss.SearchRelease) }) router.Route("/artist", func(router chi.Router) { diff --git a/internal/server/routes/routes.go b/internal/server/routes/routes.go @@ -18,25 +18,25 @@ func SongPath(id domain.SongID, title string) string { return fmt.Sprintf("/song/%d/%s", id, domain.Slug(title)) } -func Album(album domain.Album) string { - title := album.Title - if album.LatinTitle != nil { - title = *album.LatinTitle +func Release(release domain.Release) string { + title := release.Title + if release.LatinTitle != nil { + title = *release.LatinTitle } - return AlbumPath(album.ID, title) + return ReleasePath(release.ID, title) } -func AlbumEdit(album domain.Album) string { - return fmt.Sprintf("%s/edit", Album(album)) +func ReleaseEdit(release domain.Release) string { + return fmt.Sprintf("%s/edit", Release(release)) } -func AlbumPath(id domain.AlbumID, title string) string { - return fmt.Sprintf("/album/%d/%s", id, domain.Slug(title)) +func ReleasePath(id domain.ReleaseID, title string) string { + return fmt.Sprintf("/release/%d/%s", id, domain.Slug(title)) } -func AlbumCover(album domain.Album) string { - return fmt.Sprintf("%s/cover", Album(album)) +func ReleaseCover(release domain.Release) string { + return fmt.Sprintf("%s/cover", Release(release)) } func Artist(artist domain.Artist) string { diff --git a/internal/server/search-album.go b/internal/server/search-album.go @@ -1,57 +0,0 @@ -package server - -import ( - "context" - "errors" - "lyrics/internal/db" - "lyrics/internal/server/htmx" - 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 { - logger.Error("failed to query db for albums", "err", err) - htmx.HxError(w, http.StatusInternalServerError, "Failed to search albums.") - return - } - - switch format { - case SearchAlbumFormatSimilar: - err = similaralbumslist.SimilarAlbumsList(similaralbumslist.SimilarAlbumsListParams{ - Albums: results, - }).Render(r.Context(), w) - case SearchAlbumFormatSuggest: - err = albumsearchresults.AlbumSearchResults(albumsearchresults.AlbumSearchResultsParams{ - Albums: results, - }).Render(r.Context(), w) - } - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - - logger.Error("failed to render search album partial", "err", err) - htmx.HxError(w, http.StatusInternalServerError, "Failed to render response.") - return - } -} diff --git a/internal/server/search-release.go b/internal/server/search-release.go @@ -0,0 +1,57 @@ +package server + +import ( + "context" + "errors" + "lyrics/internal/db" + "lyrics/internal/server/htmx" + releasesearchresults "lyrics/internal/server/ui/partials/release-search-results" + similarreleaseslist "lyrics/internal/server/ui/partials/similar-releases-list" + "net/http" +) + +type SearchReleaseFormat string + +const ( + SearchReleaseFormatSimilar SearchReleaseFormat = "similar" + SearchReleaseFormatSuggest SearchReleaseFormat = "suggest" +) + +func (ss *ServerState) SearchRelease(w http.ResponseWriter, r *http.Request) { + logger := ss.logger.With("handler", "SearchRelease") + + format := SearchReleaseFormat(r.URL.Query().Get("format")) + if format != SearchReleaseFormatSimilar && format != SearchReleaseFormatSuggest { + logger.Error("invalid release search format", "format", format) + http.Error(w, "invalid format param", http.StatusBadRequest) + return + } + + release := r.URL.Query().Get("release") + results, err := db.SearchRelease(ss.db, release) + if err != nil { + logger.Error("failed to query db for releases", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to search for release.") + return + } + + switch format { + case SearchReleaseFormatSimilar: + err = similarreleaseslist.SimilarReleasesList(similarreleaseslist.SimilarReleasesListParams{ + Releases: results, + }).Render(r.Context(), w) + case SearchReleaseFormatSuggest: + err = releasesearchresults.ReleaseSearchResults(releasesearchresults.ReleaseSearchResultsParams{ + Releases: results, + }).Render(r.Context(), w) + } + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + + logger.Error("failed to render search release partial", "err", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to render response.") + return + } +} diff --git a/internal/server/ui/layouts/base/base.go b/internal/server/ui/layouts/base/base.go @@ -1,6 +1,6 @@ package base type BaseParams struct { - Title string + Title string PageStylesheets []string } diff --git a/internal/server/ui/layouts/base/base.templ b/internal/server/ui/layouts/base/base.templ @@ -6,7 +6,7 @@ templ Header() { <nav aria-label="Primary navigation"> <ul> <li><a href="/song/new">new song</a></li> - <li><a href="/album/new">new album</a></li> + <li><a href="/release/new">new release</a></li> <li><a href="/artist/new">new artist</a></li> </ul> </nav> diff --git a/internal/server/ui/partials/album-cover/album-cover.go b/internal/server/ui/partials/album-cover/album-cover.go @@ -1,21 +0,0 @@ -package albumcover - -import ( - "lyrics/internal/domain" - "lyrics/internal/external/musicbrainz" -) - -type AlbumCoverParams struct { - Album domain.Album - CoverArt *musicbrainz.CoverArt - Status AlbumCoverStatus -} - -type AlbumCoverStatus string - -const ( - AlbumCoverStatusFound AlbumCoverStatus = "found" - AlbumCoverStatusNotFound AlbumCoverStatus = "not_found" - AlbumCoverStatusNoMBID AlbumCoverStatus = "no_mbid" - AlbumCoverStatusError AlbumCoverStatus = "error" -) diff --git a/internal/server/ui/partials/album-cover/album-cover.templ b/internal/server/ui/partials/album-cover/album-cover.templ @@ -1,18 +0,0 @@ -package albumcover - -templ AlbumCover(params AlbumCoverParams) { - switch params.Status { - case AlbumCoverStatusNoMBID: - <span>No MusicBrainz release ID saved for this album.</span> - case AlbumCoverStatusError: - <span>Unable to load cover art from <a href="https://coverartarchive.org/">Cover Art Archive</a>.</span> - case AlbumCoverStatusNotFound: - <span>No cover art found on <a href="https://coverartarchive.org/">Cover Art Archive</a>.</span> - case AlbumCoverStatusFound: - <img - alt={ params.Album.Title + " cover" } - src={ params.CoverArt.URL } - title="Loaded from Cover Art Archive" - /> - } -} 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 @@ -1,7 +0,0 @@ -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 @@ -1,35 +0,0 @@ -package albumsearchresults - -import "fmt" - -templ AlbumSearchResults(params AlbumSearchResultsParams) { - if len(params.Albums) == 0 { - <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> - } else { - <fieldset class="picker-options" role="radiogroup"> - <legend>select album</legend> - <ul> - for _, album := range params.Albums { - {{ - label := album.Title - if album.LatinTitle != nil { - label = fmt.Sprintf("%s (%s)", album.Title, *album.LatinTitle) - } - }} - <li> - <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> - </li> - } - </ul> - </fieldset> - } -} diff --git a/internal/server/ui/partials/release-cover/release-cover.go b/internal/server/ui/partials/release-cover/release-cover.go @@ -0,0 +1,21 @@ +package releasecover + +import ( + "lyrics/internal/domain" + "lyrics/internal/external/musicbrainz" +) + +type ReleaseCoverParams struct { + Release domain.Release + CoverArt *musicbrainz.CoverArt + Status ReleaseCoverStatus +} + +type ReleaseCoverStatus string + +const ( + ReleaseCoverStatusFound ReleaseCoverStatus = "found" + ReleaseCoverStatusNotFound ReleaseCoverStatus = "not_found" + ReleaseCoverStatusNoMBID ReleaseCoverStatus = "no_mbid" + ReleaseCoverStatusError ReleaseCoverStatus = "error" +) diff --git a/internal/server/ui/partials/release-cover/release-cover.templ b/internal/server/ui/partials/release-cover/release-cover.templ @@ -0,0 +1,18 @@ +package releasecover + +templ ReleaseCover(params ReleaseCoverParams) { + switch params.Status { + case ReleaseCoverStatusNoMBID: + <span>No MusicBrainz release ID saved for this release.</span> + case ReleaseCoverStatusError: + <span>Unable to load cover art from <a href="https://coverartarchive.org/">Cover Art Archive</a>.</span> + case ReleaseCoverStatusNotFound: + <span>No cover art found on <a href="https://coverartarchive.org/">Cover Art Archive</a>.</span> + case ReleaseCoverStatusFound: + <img + alt={ params.Release.Title + " cover" } + src={ params.CoverArt.URL } + title="Loaded from Cover Art Archive" + /> + } +} diff --git a/internal/server/ui/partials/release-search-results/release-search-results.go b/internal/server/ui/partials/release-search-results/release-search-results.go @@ -0,0 +1,7 @@ +package releasesearchresults + +import "lyrics/internal/domain" + +type ReleaseSearchResultsParams struct { + Releases []domain.Release +} diff --git a/internal/server/ui/partials/release-search-results/release-search-results.templ b/internal/server/ui/partials/release-search-results/release-search-results.templ @@ -0,0 +1,35 @@ +package releasesearchresults + +import "fmt" + +templ ReleaseSearchResults(params ReleaseSearchResultsParams) { + if len(params.Releases) == 0 { + <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> + } else { + <fieldset class="picker-options" role="radiogroup"> + <legend>select release</legend> + <ul> + for _, release := range params.Releases { + {{ + label := release.Title + if release.LatinTitle != nil { + label = fmt.Sprintf("%s (%s)", release.Title, *release.LatinTitle) + } + }} + <li> + <label> + <input + type="radio" + value={ fmt.Sprintf("%d", release.ID) } + data-name={ release.Title } + required + @change="selected = true; releaseId = $event.target.value; releaseTitle = $event.target.dataset.name" + /> + { label } + </label> + </li> + } + </ul> + </fieldset> + } +} diff --git a/internal/server/ui/partials/similar-albums-list/similar-albums-list.go b/internal/server/ui/partials/similar-albums-list/similar-albums-list.go @@ -1,7 +0,0 @@ -package similaralbumslist - -import "lyrics/internal/domain" - -type SimilarAlbumsListParams struct { - Albums []domain.Album -} diff --git a/internal/server/ui/partials/similar-albums-list/similar-albums-list.templ b/internal/server/ui/partials/similar-albums-list/similar-albums-list.templ @@ -1,23 +0,0 @@ -package similaralbumslist - -import "fmt" - -templ SimilarAlbumsList(params SimilarAlbumsListParams) { - if len(params.Albums) >= 1 { - <p class="section-label -muted">Similar albums</p> - <ul> - for _, album := range params.Albums { - {{ - value := album.Title - - if album.LatinTitle != nil { - value = fmt.Sprintf("%s (%s)", album.Title, *album.LatinTitle) - } - }} - <li title={ value }> - { value } - </li> - } - </ul> - } -} diff --git a/internal/server/ui/partials/similar-releases-list/similar-releases-list.go b/internal/server/ui/partials/similar-releases-list/similar-releases-list.go @@ -0,0 +1,7 @@ +package similarreleaseslist + +import "lyrics/internal/domain" + +type SimilarReleasesListParams struct { + Releases []domain.Release +} diff --git a/internal/server/ui/partials/similar-releases-list/similar-releases-list.templ b/internal/server/ui/partials/similar-releases-list/similar-releases-list.templ @@ -0,0 +1,23 @@ +package similarreleaseslist + +import "fmt" + +templ SimilarReleasesList(params SimilarReleasesListParams) { + if len(params.Releases) >= 1 { + <p class="section-label -muted">Similar releases</p> + <ul> + for _, release := range params.Releases { + {{ + value := release.Title + + if release.LatinTitle != nil { + value = fmt.Sprintf("%s (%s)", release.Title, *release.LatinTitle) + } + }} + <li title={ value }> + { value } + </li> + } + </ul> + } +} diff --git a/internal/server/ui/views/album/album.css b/internal/server/ui/views/album/album.css @@ -1,25 +0,0 @@ -.album-layout { - display: flex; - gap: 2rem; - align-items: flex-start; -} - -.album-content { - flex: 1; - min-width: 0; -} - -.album-details { - flex: 0 0 18rem; -} - -@media (max-width: 700px) { - .album-layout { - flex-direction: column; - } - - .album-details { - width: 100%; - flex-basis: auto; - } -} diff --git a/internal/server/ui/views/album/album.go b/internal/server/ui/views/album/album.go @@ -1,7 +0,0 @@ -package album - -import "lyrics/internal/domain" - -type AlbumParams struct { - Album domain.Album -} diff --git a/internal/server/ui/views/album/album.templ b/internal/server/ui/views/album/album.templ @@ -1,86 +0,0 @@ -package album - -import ( - "lyrics/internal/server/routes" - "lyrics/internal/server/ui/layouts/base" - "time" -) - -templ Album(params AlbumParams) { - @base.Base(base.BaseParams{Title: params.Album.Title, PageStylesheets: []string{"album.css"}}) { - <article class="album-layout"> - <section class="album-content"> - <header> - <h1> - { params.Album.Title } - if params.Album.LatinTitle != nil { - <span>({ *params.Album.LatinTitle })</span> - } - </h1> - </header> - <section aria-label="Track list"> - if len(params.Album.Songs) == 0 { - <p class="section-label -muted">No songs yet.</p> - } else { - <ol> - for _, as := range params.Album.Songs { - <li> - <a href={ routes.Song(as.Song) }> - { as.Song.Title } - if as.Song.LatinTitle != nil { - <span>({ *as.Song.LatinTitle })</span> - } - </a> - </li> - } - </ol> - } - </section> - <footer aria-label="Album actions"> - <a href={ routes.AlbumEdit(params.Album) }>edit album</a> - </footer> - </section> - <aside class="album-details"> - <dl> - <dt>Artist(s)</dt> - <dd> - <ul> - for _, artist := range params.Album.Artists { - <li> - <a href={ routes.Artist(artist.Artist) }> - { artist.Artist.Name } - if artist.Artist.LatinName != nil { - <span>({ *artist.Artist.LatinName })</span> - } - </a> - </li> - } - </ul> - </dd> - <dt>Released</dt> - <dd> - <time datetime={ params.Album.ReleaseDate.Format(time.DateOnly) }> - { params.Album.ReleaseDate.Format("Jan 2, 2006") } - </time> - </dd> - if params.Album.MusicBrainzReleaseID != nil { - <dt>MusicBrainz release ID</dt> - <dd> - <a href={ "https://musicbrainz.org/release/" + string(*params.Album.MusicBrainzReleaseID) }> - { *params.Album.MusicBrainzReleaseID } - </a> - </dd> - <dt>Cover</dt> - <dd - hx-get={ routes.AlbumCover(params.Album) } - hx-trigger="load" - hx-swap="innerHTML" - > - <span class="section-label -muted">Loading cover art from <a href="https://coverartarchive.org/">Cover Art Archive</a>...</span> - </dd> - } - </dl> - </aside> - </article> - } -} diff --git a/internal/server/ui/views/artist/artist.css b/internal/server/ui/views/artist/artist.css @@ -1,5 +0,0 @@ -.album-title { - display: flex; - justify-content: space-between; - align-items: center; -} diff --git a/internal/server/ui/views/artist/artist.go b/internal/server/ui/views/artist/artist.go @@ -4,7 +4,7 @@ import "lyrics/internal/domain" type ArtistParams struct { Artist domain.Artist - Albums []domain.Album + Releases []domain.Release Singles []domain.Song Featured []domain.Song } diff --git a/internal/server/ui/views/artist/artist.templ b/internal/server/ui/views/artist/artist.templ @@ -7,7 +7,7 @@ import ( ) templ Artist(params ArtistParams) { - @base.Base(base.BaseParams{Title: params.Artist.Name, PageStylesheets: []string{"artist.css"}}) { + @base.Base(base.BaseParams{Title: params.Artist.Name, PageStylesheets: []string{}}) { <article> <header> <hgroup> @@ -28,25 +28,25 @@ templ Artist(params ArtistParams) { <section> <h2>Discography</h2> <ol> - for _, album := range params.Albums { + for _, release := range params.Releases { <li> <article> - <header class="album-title"> + <header class="release-title"> <h3> - <a href={ routes.Album(album) }> - { album.Title } - if album.LatinTitle != nil { - <span>({ *album.LatinTitle })</span> + <a href={ routes.Release(release) }> + { release.Title } + if release.LatinTitle != nil { + <span>({ *release.LatinTitle })</span> } </a> </h3> - <time aria-label="Release date" datetime={ album.ReleaseDate.Format(time.DateOnly) }> - { album.ReleaseDate.Format("Jan 2, 2006") } + <time aria-label="Release date" datetime={ release.ReleaseDate.Format(time.DateOnly) }> + { release.ReleaseDate.Format("Jan 2, 2006") } </time> </header> <section aria-label="Track List"> <ol> - for _, as := range album.Songs { + for _, as := range release.Songs { <li> <a href={ routes.Song(as.Song) }> { as.Song.Title } diff --git a/internal/server/ui/views/edit-album/edit-album.go b/internal/server/ui/views/edit-album/edit-album.go @@ -1,64 +0,0 @@ -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 @@ -1,9 +0,0 @@ -// 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 @@ -1,204 +0,0 @@ -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="picker-list"> - <template x-for="(item, i) in items" :key="item.uid"> - <li - x-data="item" - x-init="htmx.process($el); initLucideIcons()" - > - <div class="picker-row"> - <div class="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 .picker-options" - hx-swap="outerHTML" - name="artist" - autocomplete="off" - :required="!item.selected" - /> - </label> - <div class="picker-selected-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="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 class="picker-options" role="radiogroup" x-show="false"></fieldset> - </div> - <input type="hidden" name="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="picker-list"> - <template x-for="(item, i) in items" :key="item.id"> - <li x-init="htmx.process($el); initLucideIcons()"> - <div class="picker-row"> - <div class="picker-main"> - <strong x-text="item.title"></strong> - </div> - <div class="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> - <fieldset> - <legend>MusicBrainz</legend> - <label title="External identifier used for metadata lookups such as cover art. This app does not contact MusicBrainz when saving."> - Release ID (optional): - <input - name="musicbrainz-release-id" - type="text" - if params.Album.MusicBrainzReleaseID != nil { - value={ *params.Album.MusicBrainzReleaseID } - } - pattern={ domain.IsValidMusicBrainzIDPattern } - placeholder="e32a4ae3-67d1-4541-84d8-de6ab2d7dec1" - /> - <i data-lucide="info"></i> - </label> - </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/internal/server/ui/views/edit-release/edit-release.go b/internal/server/ui/views/edit-release/edit-release.go @@ -0,0 +1,64 @@ +package editrelease + +import ( + "encoding/json" + "lyrics/internal/domain" +) + +type EditReleaseParams struct { + Release domain.Release +} + +type editReleaseArtist struct { + UID int `json:"uid"` + Selected bool `json:"selected"` + ArtistID domain.ArtistID `json:"artistId"` + ArtistName string `json:"artistName"` +} + +type editReleaseSong struct { + ID domain.SongID `json:"id"` + Title string `json:"title"` +} + +func editReleaseArtistsJSON(artists []domain.ReleaseArtist) string { + jsonArtists := make([]editReleaseArtist, 0, len(artists)) + + for i, aa := range artists { + jsonArtists = append(jsonArtists, editReleaseArtist{ + 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 editReleaseSongsJSON(songs []domain.ReleaseSong) string { + jsonSongs := make([]editReleaseSong, 0, len(songs)) + + for _, as := range songs { + title := as.Song.Title + if as.Song.LatinTitle != nil { + title += " (" + *as.Song.LatinTitle + ")" + } + jsonSongs = append(jsonSongs, editReleaseSong{ + 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-release/edit-release.js b/internal/server/ui/views/edit-release/edit-release.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("/release") || + !evt.detail.ctx.request.action.endsWith("/edit") + ) return; + evt.detail.ctx.request.body.delete("artist"); +}); diff --git a/internal/server/ui/views/edit-release/edit-release.templ b/internal/server/ui/views/edit-release/edit-release.templ @@ -0,0 +1,204 @@ +package editrelease + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ EditRelease(params EditReleaseParams) { + @base.Base(base.BaseParams{Title: "edit " + params.Release.Title}) { + <h1>Edit release</h1> + <form + class="release-form" + hx-post={ routes.ReleaseEdit(params.Release) } + hx-swap="none" + hx-disable-elt="#save-release-btn" + > + <fieldset + x-data="{ showLatinTitle: false }" + x-init="showLatinTitle = isNonAscii($refs.releaseTitle.value)" + > + <legend>Release</legend> + <label> + title: + <input + x-ref="releaseTitle" + name="release" + type="text" + value={ params.Release.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.Release.LatinTitle != nil { + value={ *params.Release.LatinTitle } + } + :required="showLatinTitle" + pattern={ domain.IsValidSortNamePattern } + /> + </label> + </fieldset> + <fieldset + x-data={ fmt.Sprintf("{ items: %s, uid: %d }", editReleaseArtistsJSON(params.Release.Artists), len(params.Release.Artists)) } + > + <legend>Artists</legend> + <ol class="picker-list"> + <template x-for="(item, i) in items" :key="item.uid"> + <li + x-data="item" + x-init="htmx.process($el); initLucideIcons()" + > + <div class="picker-row"> + <div class="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 .picker-options" + hx-swap="outerHTML" + name="artist" + autocomplete="off" + :required="!item.selected" + /> + </label> + <div class="picker-selected-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="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 class="picker-options" role="radiogroup" x-show="false"></fieldset> + </div> + <input type="hidden" name="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.Release.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 }", editReleaseSongsJSON(params.Release.Songs)) } + > + <legend>Songs</legend> + if len(params.Release.Songs) == 0 { + <p class="section-label -muted">No songs yet.</p> + } else { + <ol class="picker-list"> + <template x-for="(item, i) in items" :key="item.id"> + <li x-init="htmx.process($el); initLucideIcons()"> + <div class="picker-row"> + <div class="picker-main"> + <strong x-text="item.title"></strong> + </div> + <div class="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> + <fieldset> + <legend>MusicBrainz</legend> + <label title="External identifier used for metadata lookups such as cover art. This app does not contact MusicBrainz when saving."> + Release ID (optional): + <input + name="musicbrainz-release-id" + type="text" + if params.Release.MusicBrainzReleaseID != nil { + value={ *params.Release.MusicBrainzReleaseID } + } + pattern={ domain.IsValidMusicBrainzIDPattern } + placeholder="e32a4ae3-67d1-4541-84d8-de6ab2d7dec1" + /> + <i data-lucide="info"></i> + </label> + </fieldset> + <footer aria-label="Form actions"> + <button class="-primary" id="save-release-btn" type="submit">save changes</button> + <a href={ routes.Release(params.Release) }>cancel</a> + </footer> + </form> + <script src="/static/js/edit-release.js" defer></script> + } +} diff --git a/internal/server/ui/views/index/index.templ b/internal/server/ui/views/index/index.templ @@ -35,12 +35,12 @@ templ Index(params IndexParams) { </a> } } - if len(song.Albums) > 0 { + if len(song.Releases) > 0 { <span>on </span> - <a href={ routes.Album(song.Albums[0].Album) }> - { song.Albums[0].Album.Title } - if song.Albums[0].Album.LatinTitle != nil { - <span>({ *song.Albums[0].Album.LatinTitle })</span> + <a href={ routes.Release(song.Releases[0].Release) }> + { song.Releases[0].Release.Title } + if song.Releases[0].Release.LatinTitle != nil { + <span>({ *song.Releases[0].Release.LatinTitle })</span> } </a> } diff --git a/internal/server/ui/views/new-album/new-album.css b/internal/server/ui/views/new-album/new-album.css @@ -1,15 +0,0 @@ -.album-form { - max-width: 42rem; - - fieldset { - margin: 1.5rem 0; - } -} - -.similar-albums { - margin: 1rem 0 1.5rem; - - p { - margin-bottom: 0.5rem; - } -} diff --git a/internal/server/ui/views/new-album/new-album.go b/internal/server/ui/views/new-album/new-album.go @@ -1,3 +0,0 @@ -package newalbum - -type NewAlbumParams struct{} diff --git a/internal/server/ui/views/new-album/new-album.js b/internal/server/ui/views/new-album/new-album.js @@ -1,6 +0,0 @@ -// 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/new")) return; - evt.detail.ctx.request.body.delete("artist"); -}); diff --git a/internal/server/ui/views/new-album/new-album.templ b/internal/server/ui/views/new-album/new-album.templ @@ -1,150 +0,0 @@ -package newalbum - -import ( - "fmt" - "lyrics/internal/domain" - "lyrics/internal/server/ui/layouts/base" - "time" -) - -templ NewAlbum(params NewAlbumParams) { - @base.Base(base.BaseParams{Title: "new album", PageStylesheets: []string{"new-album.css"}}) { - <h1>Add an album</h1> - <form - class="album-form" - hx-post="/album/new" - hx-swap="none" - hx-disable-elt="#add-album-btn" - > - <fieldset x-data="{ showLatinTitle: false }"> - <legend>Album</legend> - <label> - title: - <input - hx-get="/album/search?format=similar" - hx-trigger="input changed delay:500ms" - hx-target="#similar-albums" - hx-swap="innerHTML" - name="album" - type="text" - pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } - required - @input="showLatinTitle = isNonAscii($event.target.value)" - /> - </label> - <label x-show="showLatinTitle"> - latin title: - <input - name="latin-title" - type="text" - :required="showLatinTitle" - pattern={ domain.IsValidSortNamePattern } - /> - </label> - </fieldset> - <section id="similar-albums" class="similar-albums" aria-label="Similar albums"></section> - <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> - <legend>Artists</legend> - <ol class="picker-list"> - <template x-for="(item, i) in items" :key="item.id"> - <li - x-data="{ selected: false, artistId: '', artistName: '' }" - x-init="htmx.process($el); initLucideIcons()" - > - <div class="picker-row"> - <div class="picker-main"> - <label x-show="!selected"> - <span>artist:</span> - <input - type="search" - hx-get="/artist/search?format=suggest" - hx-trigger="input changed delay:500ms" - hx-target="next .picker-options" - hx-swap="outerHTML" - name="artist" - autocomplete="off" - :required="!selected" - /> - </label> - <div class="picker-selected-item" x-show="selected"> - <strong x-text="artistName"></strong> - <button - type="button" - class="-secondary" - @click="selected = false; artistId = ''; artistName = ''" - > - change - </button> - </div> - </div> - <div class="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="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="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="!selected"> - <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> - </div> - <input type="hidden" name="artist-id" :value="artistId"/> - </li> - </template> - </ol> - <button class="-secondary" type="button" @click="items.push({ id: uid++ })"> - add additional artist - </button> - </fieldset> - <fieldset> - <legend>Release</legend> - <label> - release date: - <input - name="release-date" - type="date" - min="1000-01-01" - max={ fmt.Sprintf("%s", time.Now().AddDate(3, 0, 0).Format("2006-01-02")) } - required - /> - </label> - </fieldset> - <fieldset> - <legend>MusicBrainz</legend> - <label title="External identifier used for metadata lookups such as cover art. This app does not contact MusicBrainz when saving."> - Release ID (optional): - <input - name="musicbrainz-release-id" - type="text" - pattern={ domain.IsValidMusicBrainzIDPattern } - placeholder="e32a4ae3-67d1-4541-84d8-de6ab2d7dec1" - /> - <i data-lucide="info"></i> - </label> - </fieldset> - <footer aria-label="Form actions"> - <button class="-primary" id="add-album-btn" type="submit">add album</button> - </footer> - </form> - <script src="/static/js/new-album.js" defer></script> - } -} diff --git a/internal/server/ui/views/new-release/new-release.css b/internal/server/ui/views/new-release/new-release.css @@ -0,0 +1,15 @@ +.release-form { + max-width: 42rem; + + fieldset { + margin: 1.5rem 0; + } +} + +.similar-releases { + margin: 1rem 0 1.5rem; + + p { + margin-bottom: 0.5rem; + } +} diff --git a/internal/server/ui/views/new-release/new-release.go b/internal/server/ui/views/new-release/new-release.go @@ -0,0 +1,3 @@ +package newrelease + +type NewReleaseParams struct{} diff --git a/internal/server/ui/views/new-release/new-release.js b/internal/server/ui/views/new-release/new-release.js @@ -0,0 +1,6 @@ +// 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("/release/new")) return; + evt.detail.ctx.request.body.delete("artist"); +}); diff --git a/internal/server/ui/views/new-release/new-release.templ b/internal/server/ui/views/new-release/new-release.templ @@ -0,0 +1,150 @@ +package newrelease + +import ( + "fmt" + "lyrics/internal/domain" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ NewRelease(params NewReleaseParams) { + @base.Base(base.BaseParams{Title: "new release", PageStylesheets: []string{"new-release.css"}}) { + <h1>Add a release</h1> + <form + class="release-form" + hx-post="/release/new" + hx-swap="none" + hx-disable-elt="#add-release-btn" + > + <fieldset x-data="{ showLatinTitle: false }"> + <legend>Release</legend> + <label> + title: + <input + hx-get="/release/search?format=similar" + hx-trigger="input changed delay:500ms" + hx-target="#similar-releases" + hx-swap="innerHTML" + name="release" + type="text" + pattern={ domain.NoTrailingOrLeadingSpaceRegex.String() } + required + @input="showLatinTitle = isNonAscii($event.target.value)" + /> + </label> + <label x-show="showLatinTitle"> + latin title: + <input + name="latin-title" + type="text" + :required="showLatinTitle" + pattern={ domain.IsValidSortNamePattern } + /> + </label> + </fieldset> + <section id="similar-releases" class="similar-releases" aria-label="Similar releases"></section> + <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> + <legend>Artists</legend> + <ol class="picker-list"> + <template x-for="(item, i) in items" :key="item.id"> + <li + x-data="{ selected: false, artistId: '', artistName: '' }" + x-init="htmx.process($el); initLucideIcons()" + > + <div class="picker-row"> + <div class="picker-main"> + <label x-show="!selected"> + <span>artist:</span> + <input + type="search" + hx-get="/artist/search?format=suggest" + hx-trigger="input changed delay:500ms" + hx-target="next .picker-options" + hx-swap="outerHTML" + name="artist" + autocomplete="off" + :required="!selected" + /> + </label> + <div class="picker-selected-item" x-show="selected"> + <strong x-text="artistName"></strong> + <button + type="button" + class="-secondary" + @click="selected = false; artistId = ''; artistName = ''" + > + change + </button> + </div> + </div> + <div class="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="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="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="!selected"> + <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> + </div> + <input type="hidden" name="artist-id" :value="artistId"/> + </li> + </template> + </ol> + <button class="-secondary" type="button" @click="items.push({ id: uid++ })"> + add additional artist + </button> + </fieldset> + <fieldset> + <legend>Release</legend> + <label> + release date: + <input + name="release-date" + type="date" + min="1000-01-01" + max={ fmt.Sprintf("%s", time.Now().AddDate(3, 0, 0).Format("2006-01-02")) } + required + /> + </label> + </fieldset> + <fieldset> + <legend>MusicBrainz</legend> + <label title="External identifier used for metadata lookups such as cover art. This app does not contact MusicBrainz when saving."> + Release ID (optional): + <input + name="musicbrainz-release-id" + type="text" + pattern={ domain.IsValidMusicBrainzIDPattern } + placeholder="e32a4ae3-67d1-4541-84d8-de6ab2d7dec1" + /> + <i data-lucide="info"></i> + </label> + </fieldset> + <footer aria-label="Form actions"> + <button class="-primary" id="add-release-btn" type="submit">add release</button> + </footer> + </form> + <script src="/static/js/new-release.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,6 +3,6 @@ const form = document.querySelector("form"); form.addEventListener("htmx:config:request", function(evt) { if (!evt.detail.ctx.request.action.startsWith("/song/new")) return; evt.detail.ctx.request.body.delete("artist"); - evt.detail.ctx.request.body.delete("album"); + evt.detail.ctx.request.body.delete("release"); 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 @@ -39,34 +39,34 @@ templ NewSong(params NewSongParams) { </label> </fieldset> <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> - <legend>Albums</legend> + <legend>Releases</legend> <ol class="picker-list"> <template x-for="(item, i) in items" :key="item.id"> <li - x-data="{ selected: false, albumId: '', albumTitle: '' }" + x-data="{ selected: false, releaseId: '', releaseTitle: '' }" x-init="htmx.process($el); initLucideIcons()" > <div class="picker-row"> <div class="picker-main"> <label x-show="!selected"> - <span>album:</span> + <span>release:</span> <input type="search" - hx-get="/album/search?format=suggest" + hx-get="/release/search?format=suggest" hx-trigger="input changed delay:500ms" hx-target="next .picker-options" hx-swap="outerHTML" - name="album" + name="release" autocomplete="off" :required="!selected && id > 0" /> </label> <div class="picker-selected-item" x-show="selected"> - <strong x-text="albumTitle"></strong> + <strong x-text="releaseTitle"></strong> <button type="button" class="-secondary" - @click="selected = false; albumId = ''; albumTitle = ''" + @click="selected = false; releaseId = ''; releaseTitle = ''" > change </button> @@ -86,12 +86,12 @@ templ NewSong(params NewSongParams) { <div x-show="!selected"> <fieldset class="picker-options" role="radiogroup" x-show="false"></fieldset> </div> - <input type="hidden" name="album-id" :value="albumId"/> + <input type="hidden" name="release-id" :value="releaseId"/> </li> </template> </ol> <button class="-secondary" type="button" @click="items.push({ id: uid++ })"> - add additional album + add additional release </button> </fieldset> <fieldset x-data="{ items: [{ id: 0 }], uid: 1 }"> diff --git a/internal/server/ui/views/release/release.css b/internal/server/ui/views/release/release.css @@ -0,0 +1,25 @@ +.release-layout { + display: flex; + gap: 2rem; + align-items: flex-start; +} + +.release-content { + flex: 1; + min-width: 0; +} + +.release-details { + flex: 0 0 18rem; +} + +@media (max-width: 700px) { + .release-layout { + flex-direction: column; + } + + .release-details { + width: 100%; + flex-basis: auto; + } +} diff --git a/internal/server/ui/views/release/release.go b/internal/server/ui/views/release/release.go @@ -0,0 +1,7 @@ +package release + +import "lyrics/internal/domain" + +type ReleaseParams struct { + Release domain.Release +} diff --git a/internal/server/ui/views/release/release.templ b/internal/server/ui/views/release/release.templ @@ -0,0 +1,86 @@ +package release + +import ( + "lyrics/internal/server/routes" + "lyrics/internal/server/ui/layouts/base" + "time" +) + +templ Release(params ReleaseParams) { + @base.Base(base.BaseParams{Title: params.Release.Title, PageStylesheets: []string{"release.css"}}) { + <article class="release-layout"> + <section class="release-content"> + <header> + <h1> + { params.Release.Title } + if params.Release.LatinTitle != nil { + <span>({ *params.Release.LatinTitle })</span> + } + </h1> + </header> + <section aria-label="Track list"> + if len(params.Release.Songs) == 0 { + <p class="section-label -muted">No songs yet.</p> + } else { + <ol> + for _, as := range params.Release.Songs { + <li> + <a href={ routes.Song(as.Song) }> + { as.Song.Title } + if as.Song.LatinTitle != nil { + <span>({ *as.Song.LatinTitle })</span> + } + </a> + </li> + } + </ol> + } + </section> + <footer aria-label="Release actions"> + <a href={ routes.ReleaseEdit(params.Release) }>edit release</a> + </footer> + </section> + <aside class="release-details"> + <dl> + <dt>Artist(s)</dt> + <dd> + <ul> + for _, artist := range params.Release.Artists { + <li> + <a href={ routes.Artist(artist.Artist) }> + { artist.Artist.Name } + if artist.Artist.LatinName != nil { + <span>({ *artist.Artist.LatinName })</span> + } + </a> + </li> + } + </ul> + </dd> + <dt>Released</dt> + <dd> + <time datetime={ params.Release.ReleaseDate.Format(time.DateOnly) }> + { params.Release.ReleaseDate.Format("Jan 2, 2006") } + </time> + </dd> + if params.Release.MusicBrainzReleaseID != nil { + <dt>MusicBrainz release ID</dt> + <dd> + <a href={ "https://musicbrainz.org/release/" + string(*params.Release.MusicBrainzReleaseID) }> + { *params.Release.MusicBrainzReleaseID } + </a> + </dd> + <dt>Cover</dt> + <dd + hx-get={ routes.ReleaseCover(params.Release) } + hx-trigger="load" + hx-swap="innerHTML" + > + <span class="section-label -muted">Loading cover art from <a href="https://coverartarchive.org/">Cover Art Archive</a>...</span> + </dd> + } + </dl> + </aside> + </article> + } +} diff --git a/internal/server/ui/views/song/song.templ b/internal/server/ui/views/song/song.templ @@ -58,15 +58,15 @@ templ Song(params SongParams) { } </ul> </dd> - <dt>Album(s)</dt> + <dt>Release(s)</dt> <dd> <ul> - for _, album := range params.Song.Albums { + for _, release := range params.Song.Releases { <li> - <a href={ routes.Album(album.Album) }> - { album.Album.Title } - if album.Album.LatinTitle != nil { - <span>({ *album.Album.LatinTitle })</span> + <a href={ routes.Release(release.Release) }> + { release.Release.Title } + if release.Release.LatinTitle != nil { + <span>({ *release.Release.LatinTitle })</span> } </a> </li> diff --git a/scripts/build-assets.sh b/scripts/build-assets.sh @@ -15,9 +15,8 @@ cp static/vendor/*.min.js static/files/js/ minify internal/server/ui/style.css -o static/files/style.css minify internal/server/toast/toast.css -o static/files/toast.css minify internal/server/ui/views/song/song.css -o static/files/song.css -minify internal/server/ui/views/artist/artist.css -o static/files/artist.css -minify internal/server/ui/views/album/album.css -o static/files/album.css -minify internal/server/ui/views/new-album/new-album.css -o static/files/new-album.css +minify internal/server/ui/views/release/release.css -o static/files/release.css +minify internal/server/ui/views/new-release/new-release.css -o static/files/new-release.css minify internal/server/ui/views/new-song/new-song.css -o static/files/new-song.css minify internal/server/ui/picker-list.css -o static/files/picker-list.css minify static/minimal.css -o static/files/minimal.css @@ -25,6 +24,6 @@ minify static/minimal.css -o static/files/minimal.css 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/ui/views/new-release/new-release.js -o static/files/js/new-release.js +minify internal/server/ui/views/edit-release/edit-release.js -o static/files/js/edit-release.js minify internal/server/toast/toast.js -o static/files/js/toast.js diff --git a/tasks/add_release_types.txt b/tasks/add_release_types.txt @@ -0,0 +1,10 @@ +(A) add release types @feat +id: tqszpqrlzkvqrqkz + +[x] "album" is renamed to "release" +[ ] follow musicbrainz modal of "album", "single", "ep", "other" +[ ] songs must have an explicit release attached to them. +[ ] new song page has a "create as single" which will + - create a song + - create a release with type single + - link the two together diff --git a/tasks/done/style_and_improve_ux_for_new_song_form.txt b/tasks/done/style_and_improve_ux_for_new_song_form.txt @@ -1,3 +1,4 @@ style and improve ux for new song form @ux +song - +id: mkmuoutrvtxkyxxs + this should follow the general style of the [new|edit] album page diff --git a/tasks/style_and_improve_ux_for_new_song_form.txt b/tasks/style_and_improve_ux_for_new_song_form.txt @@ -1,4 +0,0 @@ -style and improve ux for new song form @ux +song -id: mkmuoutrvtxkyxxs - -this should follow the general style of the [new|edit] album page