From e7ba34710cf598c3cf9920a7d0671738f6616c09 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:03:05 -0500 Subject: [PATCH] feat: lastfm image support (#166) * feat: lastfm image support * docs --- .../content/docs/reference/configuration.md | 3 + engine/engine.go | 1 + internal/cfg/cfg.go | 9 + internal/images/imagesrc.go | 37 ++- internal/images/lastfm.go | 298 ++++++++++++++++++ 5 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 internal/images/lastfm.go diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 67c4a2b..6eae82b 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -82,6 +82,9 @@ If the environment variable is defined without **and** with the suffix at the sa If Koito is unable to validate your Subsonic configuration, it will fail to start. If you notice your container isn't running after changing these parameters, check the logs! ::: +##### KOITO_LASTFM_API_KEY +- Required: `false` +- Description: Your LastFM API key, which will be used for fetching images if provided. You can get an API key [here](https://www.last.fm/api/authentication), ##### KOITO_SKIP_IMPORT - Default: `false` - Description: Skips running the importer on startup. diff --git a/engine/engine.go b/engine/engine.go index 9374819..7de9254 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -138,6 +138,7 @@ func Run( EnableCAA: !cfg.CoverArtArchiveDisabled(), EnableDeezer: !cfg.DeezerDisabled(), EnableSubsonic: cfg.SubsonicEnabled(), + EnableLastFM: cfg.LastFMApiKey() != "", }) l.Info().Msg("Engine: Image sources initialized") diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 9e537eb..36478b1 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -38,6 +38,7 @@ const ( DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL" SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS" + LASTFM_API_KEY_ENV = "KOITO_LASTFM_API_KEY" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" @@ -72,6 +73,7 @@ type config struct { disableMusicBrainz bool subsonicUrl string subsonicParams string + lastfmApiKey string subsonicEnabled bool skipImport bool fetchImageDuringImport bool @@ -165,6 +167,7 @@ func loadConfig(getenv func(string) string, version string) (*config, error) { 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) } + cfg.lastfmApiKey = getenv(LASTFM_API_KEY_ENV) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version) @@ -361,6 +364,12 @@ func SubsonicParams() string { return globalConfig.subsonicParams } +func LastFMApiKey() string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.lastfmApiKey +} + func SkipImport() bool { lock.RLock() defer lock.RUnlock() diff --git a/internal/images/imagesrc.go b/internal/images/imagesrc.go index 717b862..46fe87a 100644 --- a/internal/images/imagesrc.go +++ b/internal/images/imagesrc.go @@ -17,6 +17,8 @@ type ImageSource struct { deezerC *DeezerClient subsonicEnabled bool subsonicC *SubsonicClient + lastfmEnabled bool + lastfmC *LastFMClient caaEnabled bool } type ImageSourceOpts struct { @@ -24,6 +26,7 @@ type ImageSourceOpts struct { EnableCAA bool EnableDeezer bool EnableSubsonic bool + EnableLastFM bool } var once sync.Once @@ -57,6 +60,10 @@ func Initialize(opts ImageSourceOpts) { imgsrc.subsonicEnabled = true 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 { 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 { img, err := imgsrc.deezerC.GetArtistImages(ctx, opts.Aliases) 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") return "", nil } + func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { l := logger.FromContext(ctx) if imgsrc.subsonicEnabled { @@ -109,9 +127,12 @@ func GetAlbumImage(ctx context.Context, opts AlbumImageOpts) (string, error) { 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") - } - if resp.StatusCode == 200 { - return url, nil + } 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 { @@ -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 { l.Debug().Msg("Attempting to find album image from Deezer") img, err := imgsrc.deezerC.GetAlbumImages(ctx, opts.Artists, opts.Album) diff --git a/internal/images/lastfm.go b/internal/images/lastfm.go new file mode 100644 index 0000000..f35f6a3 --- /dev/null +++ b/internal/images/lastfm.go @@ -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)