lyrics

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

commit 914e7bd12e1e540ad27fb2efd26c897de535d052
parent 9ae8db3e88a3309188c71e2596c1687887fbce1d
Author: brookjeynes <me@brookjeynes.dev>
Date:   Sun, 14 Jun 2026 17:48:18 +1000

feat(albums): load cover art from MusicBrainz

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

Diffstat:
Ainternal/external/musicbrainz/musicbrainz.go | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/server/album-cover.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/server/router.go | 1+
Minternal/server/routes/routes.go | 4++++
Ainternal/server/ui/partials/album-cover/album-cover.go | 21+++++++++++++++++++++
Ainternal/server/ui/partials/album-cover/album-cover.templ | 18++++++++++++++++++
Minternal/server/ui/views/album/album.templ | 16++++++++++++++++
Minternal/server/ui/views/new-album/new-album.css | 4----
8 files changed, 270 insertions(+), 4 deletions(-)

diff --git a/internal/external/musicbrainz/musicbrainz.go b/internal/external/musicbrainz/musicbrainz.go @@ -0,0 +1,131 @@ +package musicbrainz + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type CoverArtQuality string + +const ( + CoverArtQuality250 CoverArtQuality = "250" + CoverArtQuality500 CoverArtQuality = "500" + CoverArtQuality1200 CoverArtQuality = "1200" + CoverArtQualitySmall CoverArtQuality = "small" + CoverArtQualityLarge CoverArtQuality = "large" + CoverArtQualityOriginal CoverArtQuality = "original" +) + +type CoverArtResponse struct { + Images []CoverArtImage `json:"images"` +} + +type CoverArtImage struct { + Front bool `json:"front"` + Approved bool `json:"approved"` + Image string `json:"image"` + Thumbnails map[string]string `json:"thumbnails"` +} + +type CoverArt struct { + URL string + Quality CoverArtQuality +} + +var client = &http.Client{ + Timeout: 5 * time.Second, +} + +func FetchCoverArt(ctx context.Context, mbid string, quality CoverArtQuality) (*CoverArt, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://coverartarchive.org/release/"+mbid, + nil, + ) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "lyrics/0.1") + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + return nil, nil + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("cover art request failed: %s", res.Status) + } + + var data CoverArtResponse + if err := json.NewDecoder(res.Body).Decode(&data); err != nil { + return nil, err + } + + for _, image := range data.Images { + if !image.Front { + continue + } + + url, actualQuality := coverArtURL(image, quality) + if url == "" { + return nil, nil + } + + return &CoverArt{ + URL: url, + Quality: actualQuality, + }, nil + } + + return nil, nil +} + +func coverArtURL(image CoverArtImage, quality CoverArtQuality) (string, CoverArtQuality) { + switch quality { + case CoverArtQualityOriginal: + if image.Image != "" { + return image.Image, CoverArtQualityOriginal + } + fallthrough + case CoverArtQuality1200: + if url := image.Thumbnails["1200"]; url != "" { + return url, CoverArtQuality1200 + } + fallthrough + case CoverArtQualityLarge: + if url := image.Thumbnails["large"]; url != "" { + return url, CoverArtQuality500 + } + fallthrough + case CoverArtQuality500: + if url := image.Thumbnails["500"]; url != "" { + return url, CoverArtQuality500 + } + fallthrough + case CoverArtQualitySmall: + if url := image.Thumbnails["small"]; url != "" { + return url, CoverArtQuality250 + } + fallthrough + case CoverArtQuality250: + if url := image.Thumbnails["250"]; url != "" { + return url, CoverArtQuality250 + } + } + + if image.Image != "" { + return image.Image, CoverArtQualityOriginal + } + + return "", "" +} diff --git a/internal/server/album-cover.go b/internal/server/album-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" + 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/router.go b/internal/server/router.go @@ -27,6 +27,7 @@ func (ss *ServerState) Router() http.Handler { 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("/new", ss.NewAlbum) router.Post("/new", ss.NewAlbum) diff --git a/internal/server/routes/routes.go b/internal/server/routes/routes.go @@ -35,6 +35,10 @@ func AlbumPath(id domain.AlbumID, title string) string { return fmt.Sprintf("/album/%d/%s", id, domain.Slug(title)) } +func AlbumCover(album domain.Album) string { + return fmt.Sprintf("%s/cover", Album(album)) +} + func Artist(artist domain.Artist) string { name := artist.Name if artist.LatinName != nil { diff --git a/internal/server/ui/partials/album-cover/album-cover.go b/internal/server/ui/partials/album-cover/album-cover.go @@ -0,0 +1,21 @@ +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 @@ -0,0 +1,18 @@ +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/views/album/album.templ b/internal/server/ui/views/album/album.templ @@ -63,6 +63,22 @@ templ Album(params AlbumParams) { { 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/new-album/new-album.css b/internal/server/ui/views/new-album/new-album.css @@ -16,10 +16,6 @@ .artist-picker-list { padding-left: 1.5rem; - - li { - margin-bottom: 0.75rem; - } } .artist-picker-row {