Files
minstrel/internal/catalogue/art.go
T
bvandeusenandClaude Opus 5.5 77389f88bc
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
feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
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>
2026-10-09 21:08:22 -04:00

215 lines
6.5 KiB
Go

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
}