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