feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s

Minstrel keeps its own record of what artists have released, read from
MusicBrainz, so the wanted list, search and import no longer need Lidarr.

- internal/musicbrainz: searches (artists, release groups, recordings) and
  browses (an artist's release groups; a group's releases with track lists).
  One process-wide pacer at 1.1s, shared with tag enrichment, because
  MusicBrainz's limit is per address. A 503 throttle is retried.
- Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups
  (a collaboration belongs to each credited artist), mb_releases,
  mb_release_tracks.
- internal/catalogue: Discography and Releases serve the held copy and
  re-read it when older than 7 or 30 days. An unreachable MusicBrainz
  serves the stale copy, marked. An hourly pass re-reads due artists.
- Art for what isn't held comes through /api/catalogue/art from the
  enabled cover-art sources (Cover Art Archive by release group, then the
  name-based sources) and is kept on disk, so the browser never fetches a
  third-party host. A miss is a 404 and the card shows its icon.
- Request search moves from /api/lidarr/search to /api/catalogue/search,
  with the same response shape. Albums are release groups, and "in
  library" matches albums.release_group_mbid. A 10-minute cache absorbs
  search-as-you-type. Discover suggestions point at the art endpoint
  instead of looking up Lidarr.
- Android calls the new path and loads Discover art through ServerImage.
  Installed builds older than this one lose Discover search until they
  update (no compatibility path, rule 22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:08:22 -04:00
co-authored by Claude Opus 5.5
parent a2e1890d4c
commit 77389f88bc
40 changed files with 3223 additions and 577 deletions
+214
View File
@@ -0,0 +1,214 @@
package catalogue
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sync"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
)
// NoArtRetry is how long "no source has art for this" is believed before
// the sources are asked again.
const NoArtRetry = 7 * 24 * time.Hour
// fetchTimeout bounds one look-up across every source (rule 156).
const fetchTimeout = 30 * time.Second
// ErrNoArt: no enabled source has an image, or the sources could not be
// reached. The caller shows its placeholder.
var ErrNoArt = errors.New("catalogue: no art")
var mbidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// ValidMBID reports whether s is shaped like a MusicBrainz id. It is the
// guard that keeps a request path out of the cache directory's parents.
func ValidMBID(s string) bool { return mbidPattern.MatchString(s) }
// Art serves images for artists and release groups that are not in the
// library (search results, Discover suggestions, wanted albums), from the
// operator's enabled cover-art sources. Each image is fetched once and kept
// under the data directory, so the browser only ever talks to Minstrel.
type Art struct {
dir string
artistSources func() []coverart.ArtistArtProvider
albumSources func() []coverart.AlbumCoverProvider
now func() time.Time
mu sync.Mutex
inflight map[string]*artCall
}
type artCall struct {
done chan struct{}
body []byte
err error
}
// ArtSources lists the image sources to ask, in order. The cover-art
// settings service is one: it answers with what the operator has enabled.
type ArtSources interface {
EnabledArtistProviders() []coverart.ArtistArtProvider
EnabledAlbumProviders() []coverart.AlbumCoverProvider
}
// NewArt keeps images under dataDir/catalogue-art and asks the given
// sources. Nil sources ask nothing, so every image is the placeholder.
func NewArt(dataDir string, sources ArtSources) *Art {
a := &Art{dir: filepath.Join(dataDir, "catalogue-art"), now: time.Now, inflight: map[string]*artCall{}}
if sources != nil {
a.artistSources = sources.EnabledArtistProviders
a.albumSources = sources.EnabledAlbumProviders
}
return a
}
// Artist returns an image of the artist: the first enabled artist source
// with one wins, its thumbnail preferred over its backdrop.
func (a *Art) Artist(ctx context.Context, mbid, name string) ([]byte, error) {
return a.cached(ctx, "artist", mbid, func(ctx context.Context) ([]byte, error) {
if a.artistSources == nil {
return nil, coverart.ErrNotFound
}
ref := coverart.ArtistRef{MBID: mbid, Name: name}
return firstOf(a.artistSources(), func(p coverart.ArtistArtProvider) ([]byte, error) {
thumb, fanart, err := p.FetchArtistArt(ctx, ref)
if err == nil && len(thumb) == 0 {
thumb = fanart
}
return thumb, err
})
})
}
// ReleaseGroup returns a cover for the release group: a source that can
// answer by group (the Cover Art Archive) is asked by its id, the rest by
// artist and title.
func (a *Art) ReleaseGroup(ctx context.Context, mbid, artist, title string) ([]byte, error) {
return a.cached(ctx, "release-group", mbid, func(ctx context.Context) ([]byte, error) {
if a.albumSources == nil {
return nil, coverart.ErrNotFound
}
byName := coverart.AlbumRef{ArtistName: artist, AlbumTitle: title}
return firstOf(a.albumSources(), func(p coverart.AlbumCoverProvider) ([]byte, error) {
if g, ok := p.(coverart.ReleaseGroupCoverProvider); ok {
return g.FetchReleaseGroupCover(ctx, mbid)
}
if artist == "" || title == "" {
return nil, coverart.ErrNotFound
}
return p.FetchAlbumCover(ctx, byName)
})
})
}
// firstOf asks each source in turn. It reports ErrNotFound only when every
// source said so; if any could not be reached, the miss is transient.
func firstOf[P any](sources []P, ask func(P) ([]byte, error)) ([]byte, error) {
transient := false
for _, p := range sources {
body, err := ask(p)
if err == nil && len(body) > 0 {
return body, nil
}
if err != nil && !errors.Is(err, coverart.ErrNotFound) {
transient = true
}
}
if transient {
return nil, coverart.ErrTransient
}
return nil, coverart.ErrNotFound
}
// cached serves kind/mbid from disk, or fetches it once however many
// requests ask at the same moment. A definite "nobody has one" is written
// down and believed for NoArtRetry; an unreachable source is not, so the
// next request asks again.
func (a *Art) cached(ctx context.Context, kind, mbid string, fetch func(context.Context) ([]byte, error)) ([]byte, error) {
if !ValidMBID(mbid) {
return nil, ErrNoArt
}
dir := filepath.Join(a.dir, kind)
imgPath := filepath.Join(dir, mbid)
nonePath := imgPath + ".none"
if body, err := os.ReadFile(imgPath); err == nil && len(body) > 0 {
return body, nil
}
if fi, err := os.Stat(nonePath); err == nil && a.now().Sub(fi.ModTime()) < NoArtRetry {
return nil, ErrNoArt
}
key := kind + "/" + mbid
a.mu.Lock()
if call, ok := a.inflight[key]; ok {
a.mu.Unlock()
select {
case <-call.done:
return call.body, call.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
call := &artCall{done: make(chan struct{})}
a.inflight[key] = call
a.mu.Unlock()
defer func() {
a.mu.Lock()
delete(a.inflight, key)
a.mu.Unlock()
close(call.done)
}()
// The fetch outlives a browser that navigates away: a second request is
// likely, and the image is worth keeping.
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fetchTimeout)
defer cancel()
body, err := fetch(fctx)
switch {
case err == nil && len(body) > 0:
if werr := writeFile(dir, imgPath, body); werr != nil {
// Serving it uncached is still better than a placeholder.
call.body = body
return body, nil
}
_ = os.Remove(nonePath)
call.body = body
return body, nil
case err == nil || errors.Is(err, coverart.ErrNotFound):
_ = writeFile(dir, nonePath, nil)
}
call.err = ErrNoArt
return nil, ErrNoArt
}
func writeFile(dir, path string, body []byte) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("catalogue art: %w", err)
}
tmp, err := os.CreateTemp(dir, ".part-*")
if err != nil {
return fmt.Errorf("catalogue art: %w", err)
}
if _, err := tmp.Write(body); err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return fmt.Errorf("catalogue art: %w", err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmp.Name())
return fmt.Errorf("catalogue art: %w", err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
_ = os.Remove(tmp.Name())
return fmt.Errorf("catalogue art: %w", err)
}
return nil
}