Koito/internal/images/imagesrc.go
safierinx-a 8938390106 Fix artist images: filter Last.fm placeholder, reorder providers, fix alias bug
Three bugs causing all artist images to be the same Last.fm placeholder:

1. Last.fm stopped serving real artist images years ago and returns a
   generic placeholder (hash 2a96cbd8b46e442fc41c2b86b821562f) for
   every artist. Added filter in selectBestImage to reject URLs
   containing this known placeholder hash.

2. Provider order had Last.fm before Deezer for artist images. Since
   Last.fm "succeeded" with the placeholder, Deezer was never reached.
   Swapped order: Deezer now checked before Last.fm.

3. FetchMissingArtistImages had inverted if/else — aliases were used
   on error, bare name on success. Fixed condition to err == nil.
2026-03-27 03:16:35 +05:30

189 lines
5.3 KiB
Go

// package imagesrc defines interfaces for album and artist image providers
package images
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"github.com/gabehf/koito/internal/logger"
"github.com/google/uuid"
)
type ImageSource struct {
deezerEnabled bool
deezerC *DeezerClient
subsonicEnabled bool
subsonicC *SubsonicClient
lastfmEnabled bool
lastfmC *LastFMClient
caaEnabled bool
}
type ImageSourceOpts struct {
UserAgent string
EnableCAA bool
EnableDeezer bool
EnableSubsonic bool
EnableLastFM bool
}
var once sync.Once
var imgsrc ImageSource
type ArtistImageOpts struct {
Aliases []string
MBID *uuid.UUID
}
type AlbumImageOpts struct {
Artists []string
Album string
ReleaseMbzID *uuid.UUID
ReleaseGroupMbzID *uuid.UUID
}
const caaBaseUrl = "https://coverartarchive.org"
// all functions are no-op if no providers are enabled
func Initialize(opts ImageSourceOpts) {
once.Do(func() {
if opts.EnableCAA {
imgsrc.caaEnabled = true
}
if opts.EnableDeezer {
imgsrc.deezerEnabled = true
imgsrc.deezerC = NewDeezerClient()
}
if opts.EnableSubsonic {
imgsrc.subsonicEnabled = true
imgsrc.subsonicC = NewSubsonicClient()
}
if opts.EnableLastFM {
imgsrc.lastfmEnabled = true
imgsrc.lastfmC = NewLastFMClient()
}
})
}
func Shutdown() {
imgsrc.deezerC.Shutdown()
}
func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled {
img, err := imgsrc.subsonicC.GetArtistImage(ctx, opts.MBID, opts.Aliases[0])
if err != nil {
l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from Subsonic")
} else if img != "" {
return img, nil
}
} else {
l.Debug().Msg("GetArtistImage: Subsonic image fetching is disabled")
}
if imgsrc.deezerEnabled {
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
if err != nil {
l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from Deezer")
} else if img != "" {
return img, nil
}
} else {
l.Debug().Msg("GetArtistImage: Deezer image fetching is disabled")
}
if imgsrc.lastfmEnabled {
img, err := imgsrc.lastfmC.GetArtistImage(ctx, opts.MBID, opts.Aliases[0])
if err != nil {
l.Debug().Err(err).Msg("GetArtistImage: Could not find artist image from LastFM")
} else if img != "" {
return img, nil
}
} else {
l.Debug().Msg("GetArtistImage: LastFM image fetching is disabled")
}
l.Warn().Msg("GetArtistImage: No image providers are enabled")
return "", nil
}
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled {
img, err := imgsrc.subsonicC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album)
if err != nil {
l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Subsonic")
}
if img != "" {
return img, nil
}
l.Debug().Msg("Could not find album cover from Subsonic")
}
if imgsrc.caaEnabled {
l.Debug().Msg("Attempting to find album image from CoverArtArchive")
if opts.ReleaseMbzID != nil && *opts.ReleaseMbzID != uuid.Nil {
url := fmt.Sprintf(caaBaseUrl+"/release/%s/front", opts.ReleaseMbzID.String())
resp, err := http.DefaultClient.Head(url)
if err != nil {
l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release MBID")
} else {
if resp.StatusCode == 200 {
return url, nil
} else {
l.Debug().Int("status", resp.StatusCode).Msg("GetAlbumImage: Got non-OK response from CoverArtArchive")
}
}
}
if opts.ReleaseGroupMbzID != nil && *opts.ReleaseGroupMbzID != uuid.Nil {
url := fmt.Sprintf(caaBaseUrl+"/release-group/%s/front", opts.ReleaseGroupMbzID.String())
resp, err := http.DefaultClient.Head(url)
if err != nil {
l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release Group MBID")
}
if resp.StatusCode == 200 {
return url, nil
}
}
}
if imgsrc.lastfmEnabled {
img, err := imgsrc.lastfmC.GetAlbumImage(ctx, opts.ReleaseMbzID, opts.Artists[0], opts.Album)
if err != nil {
l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Subsonic")
}
if img != "" {
return img, nil
}
l.Debug().Msg("Could not find album cover from Subsonic")
}
if imgsrc.deezerEnabled {
l.Debug().Msg("Attempting to find album image from Deezer")
img, err := imgsrc.deezerC.GetAlbumImages(ctx, opts.Artists, opts.Album)
if err != nil {
l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from Deezer")
return "", err
}
return img, nil
}
l.Warn().Msg("GetAlbumImage: No image providers are enabled")
return "", nil
}
// ValidateImageURL checks if the URL points to a valid image by performing a HEAD request.
func ValidateImageURL(url string) error {
resp, err := http.Head(url)
if err != nil {
return fmt.Errorf("ValidateImageURL: http.Head: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ValidateImageURL: HEAD request failed, status code: %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("ValidateImageURL: URL does not point to an image, content type: %s", contentType)
}
return nil
}