Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
6.5 KiB
Go
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.
|
|
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
|
|
// 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 the sources an admin 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
|
|
}
|