mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
parent
56ac73d12b
commit
e7ba34710c
5 changed files with 345 additions and 3 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
298
internal/images/lastfm.go
Normal file
298
internal/images/lastfm.go
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue