mirror of
https://github.com/gabehf/Koito.git
synced 2026-04-22 12:01:52 -07:00
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.
302 lines
8.1 KiB
Go
302 lines
8.1 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
|
|
}
|
|
|
|
// lastFMPlaceholderHash is the hash of Last.fm's generic "no artist image" placeholder.
|
|
// Last.fm stopped serving real artist images years ago and returns this for nearly every artist.
|
|
const lastFMPlaceholderHash = "2a96cbd8b46e442fc41c2b86b821562f"
|
|
|
|
// 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 != "" && !strings.Contains(img.URL, lastFMPlaceholderHash) {
|
|
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)
|