mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
298 lines
7.8 KiB
Go
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)
|