feat: lastfm image support

This commit is contained in:
Gabe Farrell 2026-01-21 15:28:49 -05:00
parent 56ac73d12b
commit 9ff5427fb2
4 changed files with 342 additions and 3 deletions

View file

@ -138,6 +138,7 @@ func Run(
EnableCAA: !cfg.CoverArtArchiveDisabled(), EnableCAA: !cfg.CoverArtArchiveDisabled(),
EnableDeezer: !cfg.DeezerDisabled(), EnableDeezer: !cfg.DeezerDisabled(),
EnableSubsonic: cfg.SubsonicEnabled(), EnableSubsonic: cfg.SubsonicEnabled(),
EnableLastFM: cfg.LastFMApiKey() != "",
}) })
l.Info().Msg("Engine: Image sources initialized") l.Info().Msg("Engine: Image sources initialized")

View file

@ -38,6 +38,7 @@ const (
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL" SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL"
SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS" SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS"
LASTFM_API_KEY_ENV = "KOITO_LASTFM_API_KEY"
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
@ -72,6 +73,7 @@ type config struct {
disableMusicBrainz bool disableMusicBrainz bool
subsonicUrl string subsonicUrl string
subsonicParams string subsonicParams string
lastfmApiKey string
subsonicEnabled bool subsonicEnabled bool
skipImport bool skipImport bool
fetchImageDuringImport bool fetchImageDuringImport bool
@ -165,6 +167,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") { if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") {
return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV) return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV)
} }
cfg.lastfmApiKey = getenv(LASTFM_API_KEY_ENV)
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV))
cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version) cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version)
@ -361,6 +364,12 @@ func SubsonicParams() string {
return globalConfig.subsonicParams return globalConfig.subsonicParams
} }
func LastFMApiKey() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.lastfmApiKey
}
func SkipImport() bool { func SkipImport() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()

View file

@ -17,6 +17,8 @@ type ImageSource struct {
deezerC *DeezerClient deezerC *DeezerClient
subsonicEnabled bool subsonicEnabled bool
subsonicC *SubsonicClient subsonicC *SubsonicClient
lastfmEnabled bool
lastfmC *LastFMClient
caaEnabled bool caaEnabled bool
} }
type ImageSourceOpts struct { type ImageSourceOpts struct {
@ -24,6 +26,7 @@ type ImageSourceOpts struct {
EnableCAA bool EnableCAA bool
EnableDeezer bool EnableDeezer bool
EnableSubsonic bool EnableSubsonic bool
EnableLastFM bool
} }
var once sync.Once var once sync.Once
@ -57,6 +60,10 @@ func Initialize(opts ImageSourceOpts) {
imgsrc.subsonicEnabled = true imgsrc.subsonicEnabled = true
imgsrc.subsonicC = NewSubsonicClient() imgsrc.subsonicC = NewSubsonicClient()
} }
if opts.EnableLastFM {
imgsrc.lastfmEnabled = true
imgsrc.lastfmC = NewLastFMClient()
}
}) })
} }
@ -76,6 +83,16 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
} else { } else {
l.Debug().Msg("GetArtistImage: Subsonic image fetching is disabled") l.Debug().Msg("GetArtistImage: Subsonic 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")
}
if imgsrc.deezerEnabled { if imgsrc.deezerEnabled {
img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases) img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases)
if err != nil { if err != nil {
@ -90,6 +107,7 @@ func GetArtistImage(ctx context.Context, opts ArtistImageOpts) (string, error) {
l.Warn().Msg("GetArtistImage: No image providers are enabled") l.Warn().Msg("GetArtistImage: No image providers are enabled")
return "", nil return "", nil
} }
func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
l := logger.FromContext(ctx) l := logger.FromContext(ctx)
if imgsrc.subsonicEnabled { if imgsrc.subsonicEnabled {
@ -109,9 +127,12 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
resp, err := http.DefaultClient.Head(url) resp, err := http.DefaultClient.Head(url)
if err != nil { if err != nil {
l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release MBID") l.Debug().Err(err).Msg("GetAlbumImage: Could not find artist image from CoverArtArchive with Release MBID")
} } else {
if resp.StatusCode == 200 { if resp.StatusCode == 200 {
return url, nil 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 { if opts.ReleaseGroupMbzID != nil && *opts.ReleaseGroupMbzID != uuid.Nil {
@ -125,6 +146,16 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) {
} }
} }
} }
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 { if imgsrc.deezerEnabled {
l.Debug().Msg("Attempting to find album image from Deezer") l.Debug().Msg("Attempting to find album image from Deezer")
img, err := imgsrc.deezerC.GetAlbumImages(ctx, opts.Artists, opts.Album) img, err := imgsrc.deezerC.GetAlbumImages(ctx, opts.Artists, opts.Album)

298
internal/images/lastfm.go Normal file
View file

@ -0,0 +1,298 @@
package images
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/queue"
"github.com/google/uuid"
)
// i told gemini to write this cuz i figured it would be simple enough and
// it looks like it just works? maybe ai is actually worth one quintillion gallons of water
type LastFMClient struct {
apiKey string
baseUrl string
userAgent string
requestQueue *queue.RequestQueue
}
// LastFM JSON structures use "#text" for the value of XML-mapped fields
type lastFMImage struct {
URL string `json:"#text"`
Size string `json:"size"`
}
type lastFMAlbumResponse struct {
Album struct {
Name string `json:"name"`
Image []lastFMImage `json:"image"`
} `json:"album"`
Error int `json:"error"`
Message string `json:"message"`
}
type lastFMArtistResponse struct {
Artist struct {
Name string `json:"name"`
Image []lastFMImage `json:"image"`
} `json:"artist"`
Error int `json:"error"`
Message string `json:"message"`
}
const (
lastFMApiBaseUrl = "http://ws.audioscrobbler.com/2.0/"
)
func NewLastFMClient() *LastFMClient {
ret := new(LastFMClient)
ret.apiKey = cfg.LastFMApiKey()
ret.baseUrl = lastFMApiBaseUrl
ret.userAgent = cfg.UserAgent()
ret.requestQueue = queue.NewRequestQueue(5, 5)
return ret
}
func (c *LastFMClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
l := logger.FromContext(ctx)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
resp, err := client.Do(req)
if err != nil {
l.Debug().Err(err).Str("url", req.URL.String()).Msg("Failed to contact LastFM")
done <- queue.RequestResult{Err: err}
return
}
defer resp.Body.Close()
// LastFM might return 200 OK even for API errors (like "Artist not found"),
// so we rely on parsing the JSON body for logic errors later,
// but we still check for HTTP protocol failures here.
if resp.StatusCode >= 500 {
err = fmt.Errorf("received server error from LastFM: %s", resp.Status)
done <- queue.RequestResult{Body: nil, Err: err}
return
}
body, err := io.ReadAll(resp.Body)
done <- queue.RequestResult{Body: body, Err: err}
})
result := <-resultChan
return result.Body, result.Err
}
func (c *LastFMClient) getEntity(ctx context.Context, params url.Values, result any) error {
l := logger.FromContext(ctx)
// Add standard parameters
params.Set("api_key", c.apiKey)
params.Set("format", "json")
// Construct URL
reqUrl, _ := url.Parse(c.baseUrl)
reqUrl.RawQuery = params.Encode()
l.Debug().Msgf("Sending request to LastFM: GET %s", reqUrl.String())
req, err := http.NewRequest("GET", reqUrl.String(), nil)
if err != nil {
return fmt.Errorf("getEntity: %w", err)
}
l.Debug().Msg("Adding LastFM request to queue")
body, err := c.queue(ctx, req)
if err != nil {
l.Err(err).Msg("LastFM request failed")
return fmt.Errorf("getEntity: %w", err)
}
err = json.Unmarshal(body, result)
if err != nil {
l.Err(err).Msg("Failed to unmarshal LastFM response")
return fmt.Errorf("getEntity: %w", err)
}
return nil
}
// selectBestImage picks the largest available image from the LastFM slice
func (c *LastFMClient) selectBestImage(images []lastFMImage) string {
// Rank preference: mega > extralarge > large > medium > small
// Since LastFM usually returns them in order of size, we could take the last one,
// but a map lookup is safer against API changes.
imgMap := make(map[string]string)
for _, img := range images {
if img.URL != "" {
imgMap[img.Size] = img.URL
}
}
if url, ok := imgMap["mega"]; ok {
if err := ValidateImageURL(overrideImgSize(url)); err == nil {
return overrideImgSize(url)
} else {
return url
}
}
if url, ok := imgMap["extralarge"]; ok {
if err := ValidateImageURL(overrideImgSize(url)); err == nil {
return overrideImgSize(url)
} else {
return url
}
}
if url, ok := imgMap["large"]; ok {
if err := ValidateImageURL(overrideImgSize(url)); err == nil {
return overrideImgSize(url)
} else {
return url
}
}
if url, ok := imgMap["medium"]; ok {
return url
}
if url, ok := imgMap["small"]; ok {
return url
}
return ""
}
// lastfm seems to only return a 300x300 image even for "mega" and "extralarge" images, so I'm cheating
func overrideImgSize(url string) string {
return strings.Replace(url, "300x300", "600x600", 1)
}
func (c *LastFMClient) GetAlbumImage(ctx context.Context, mbid *uuid.UUID, artist, album string) (string, error) {
l := logger.FromContext(ctx)
resp := new(lastFMAlbumResponse)
l.Debug().Msgf("Finding album image for %s from artist %s", album, artist)
// Helper to run the fetch
fetch := func(query paramsBuilder) error {
params := url.Values{}
params.Set("method", "album.getInfo")
query(params)
return c.getEntity(ctx, params, resp)
}
// 1. Try MBID search first
if mbid != nil {
l.Debug().Str("mbid", mbid.String()).Msg("Searching album image by MBID")
err := fetch(func(p url.Values) {
p.Set("mbid", mbid.String())
})
// If success and no API error code
if err == nil && resp.Error == 0 && len(resp.Album.Image) > 0 {
best := c.selectBestImage(resp.Album.Image)
if best != "" {
return best, nil
}
} else if resp.Error != 0 {
l.Debug().Int("api_error", resp.Error).Msg("LastFM MBID lookup failed, falling back to name")
}
}
// 2. Fallback to Artist + Album name match
l.Debug().Str("title", album).Str("artist", artist).Msg("Searching album image by title and artist")
// Clear previous response structure just in case
resp = new(lastFMAlbumResponse)
err := fetch(func(p url.Values) {
p.Set("artist", artist)
p.Set("album", album)
// Auto-correct spelling is useful for name lookups
p.Set("autocorrect", "1")
})
if err != nil {
return "", fmt.Errorf("GetAlbumImage: %v", err)
}
if resp.Error != 0 {
return "", fmt.Errorf("GetAlbumImage: LastFM API error %d: %s", resp.Error, resp.Message)
}
best := c.selectBestImage(resp.Album.Image)
if best == "" {
return "", fmt.Errorf("GetAlbumImage: no suitable image found")
}
return best, nil
}
func (c *LastFMClient) GetArtistImage(ctx context.Context, mbid *uuid.UUID, artist string) (string, error) {
l := logger.FromContext(ctx)
resp := new(lastFMArtistResponse)
l.Debug().Msgf("Finding artist image for %s", artist)
fetch := func(query paramsBuilder) error {
params := url.Values{}
params.Set("method", "artist.getInfo")
query(params)
return c.getEntity(ctx, params, resp)
}
// 1. Try MBID search
if mbid != nil {
l.Debug().Str("mbid", mbid.String()).Msg("Searching artist image by MBID")
err := fetch(func(p url.Values) {
p.Set("mbid", mbid.String())
})
if err == nil && resp.Error == 0 && len(resp.Artist.Image) > 0 {
best := c.selectBestImage(resp.Artist.Image)
if best != "" {
// Validate to match Subsonic implementation behavior
if err := ValidateImageURL(best); err == nil {
return best, nil
}
}
}
}
// 2. Fallback to Artist name
l.Debug().Str("artist", artist).Msg("Searching artist image by name")
resp = new(lastFMArtistResponse)
err := fetch(func(p url.Values) {
p.Set("artist", artist)
p.Set("autocorrect", "1")
})
if err != nil {
return "", fmt.Errorf("GetArtistImage: %v", err)
}
if resp.Error != 0 {
return "", fmt.Errorf("GetArtistImage: LastFM API error %d: %s", resp.Error, resp.Message)
}
best := c.selectBestImage(resp.Artist.Image)
if best == "" {
return "", fmt.Errorf("GetArtistImage: no suitable image found")
}
if err := ValidateImageURL(best); err != nil {
return "", fmt.Errorf("GetArtistImage: failed to validate image url")
}
return best, nil
}
type paramsBuilder func(url.Values)