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 }