Koito/internal/images/lastfm.go
Gabe Farrell e7ba34710c
feat: lastfm image support (#166)
* feat: lastfm image support

* docs
2026-01-21 16:03:05 -05:00

298 lines
7.8 KiB
Go

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)