Add ExternalInformation core service (not a great name, I know)
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/navidrome/core/lastfm"
|
||||
"github.com/deluan/navidrome/core/spotify"
|
||||
"github.com/deluan/navidrome/log"
|
||||
"github.com/deluan/navidrome/model"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
const placeholderArtistImageSmallUrl = "https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
const placeholderArtistImageMediumUrl = "https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
const placeholderArtistImageLargeUrl = "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
|
||||
|
||||
type ExternalInfo interface {
|
||||
ArtistInfo(ctx context.Context, artistId string, includeNotPresent bool, count int) (*model.ArtistInfo, error)
|
||||
}
|
||||
|
||||
type LastFMClient interface {
|
||||
ArtistGetInfo(ctx context.Context, name string) (*lastfm.Artist, error)
|
||||
}
|
||||
|
||||
type SpotifyClient interface {
|
||||
ArtistImages(ctx context.Context, name string) ([]spotify.Image, error)
|
||||
}
|
||||
|
||||
func NewExternalInfo(ds model.DataStore, lfm LastFMClient, spf SpotifyClient) ExternalInfo {
|
||||
return &externalInfo{ds: ds, lfm: lfm, spf: spf}
|
||||
}
|
||||
|
||||
type externalInfo struct {
|
||||
ds model.DataStore
|
||||
lfm LastFMClient
|
||||
spf SpotifyClient
|
||||
}
|
||||
|
||||
func (e *externalInfo) ArtistInfo(ctx context.Context, artistId string,
|
||||
includeNotPresent bool, count int) (*model.ArtistInfo, error) {
|
||||
info := model.ArtistInfo{ID: artistId}
|
||||
|
||||
artist, err := e.ds.Artist(ctx).Get(artistId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.Name = artist.Name
|
||||
|
||||
// TODO Load from local: artist.jpg/png/webp, artist.json (with the remaining info)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
e.callArtistInfo(ctx, artist, includeNotPresent, &wg, &info)
|
||||
e.callArtistImages(ctx, artist, &wg, &info)
|
||||
wg.Wait()
|
||||
|
||||
// Use placeholders if could not get from external sources
|
||||
e.setBio(&info, "Biography not available")
|
||||
e.setSmallImageUrl(&info, placeholderArtistImageSmallUrl)
|
||||
e.setMediumImageUrl(&info, placeholderArtistImageMediumUrl)
|
||||
e.setLargeImageUrl(&info, placeholderArtistImageLargeUrl)
|
||||
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist.Name, "info", info)
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (e *externalInfo) callArtistInfo(ctx context.Context, artist *model.Artist, includeNotPresent bool,
|
||||
wg *sync.WaitGroup, info *model.ArtistInfo) {
|
||||
if e.lfm != nil {
|
||||
log.Debug(ctx, "Calling Last.FM ArtistGetInfo", "artist", artist.Name)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
start := time.Now()
|
||||
defer wg.Done()
|
||||
lfmArtist, err := e.lfm.ArtistGetInfo(nil, artist.Name)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling Last.FM", "artist", artist.Name, err)
|
||||
} else {
|
||||
log.Debug(ctx, "Got info from Last.FM", "artist", artist.Name, "info", lfmArtist.Bio.Summary, "elapsed", time.Since(start))
|
||||
}
|
||||
e.setBio(info, lfmArtist.Bio.Summary)
|
||||
e.setSimilar(ctx, info, lfmArtist.Similar.Artists, includeNotPresent)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalInfo) callArtistImages(ctx context.Context, artist *model.Artist, wg *sync.WaitGroup, info *model.ArtistInfo) {
|
||||
if e.spf != nil {
|
||||
log.Debug(ctx, "Calling Spotify ArtistImages", "artist", artist.Name)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
start := time.Now()
|
||||
defer wg.Done()
|
||||
spfImages, err := e.spf.ArtistImages(nil, artist.Name)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling Spotify", "artist", artist.Name, err)
|
||||
} else {
|
||||
log.Debug(ctx, "Got images from Spotify", "artist", artist.Name, "images", spfImages, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
sort.Slice(spfImages, func(i, j int) bool { return spfImages[i].Width > spfImages[j].Width })
|
||||
|
||||
if len(spfImages) >= 1 {
|
||||
e.setLargeImageUrl(info, spfImages[0].URL)
|
||||
}
|
||||
if len(spfImages) >= 2 {
|
||||
e.setMediumImageUrl(info, spfImages[1].URL)
|
||||
}
|
||||
if len(spfImages) >= 3 {
|
||||
e.setSmallImageUrl(info, spfImages[2].URL)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalInfo) setBio(info *model.ArtistInfo, bio string) {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
if info.Bio == "" {
|
||||
bio = policy.Sanitize(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
info.Bio = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalInfo) setSmallImageUrl(info *model.ArtistInfo, url string) {
|
||||
if info.SmallImageUrl == "" {
|
||||
info.SmallImageUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalInfo) setMediumImageUrl(info *model.ArtistInfo, url string) {
|
||||
if info.MediumImageUrl == "" {
|
||||
info.MediumImageUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalInfo) setLargeImageUrl(info *model.ArtistInfo, url string) {
|
||||
if info.LargeImageUrl == "" {
|
||||
info.LargeImageUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalInfo) setSimilar(ctx context.Context, info *model.ArtistInfo, artists []lastfm.Artist, includeNotPresent bool) {
|
||||
if len(info.Similar) == 0 {
|
||||
var notPresent []string
|
||||
|
||||
// First select artists that are present.
|
||||
for _, s := range artists {
|
||||
sa, err := e.ds.Artist(ctx).FindByName(s.Name)
|
||||
if err != nil {
|
||||
notPresent = append(notPresent, s.Name)
|
||||
continue
|
||||
}
|
||||
info.Similar = append(info.Similar, *sa)
|
||||
}
|
||||
|
||||
// Then fill up with non-present artists
|
||||
if includeNotPresent {
|
||||
for _, s := range notPresent {
|
||||
sa := model.Artist{ID: "-1", Name: s}
|
||||
info.Similar = append(info.Similar, sa)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
@@ -27,7 +28,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
// TODO SimilarArtists()
|
||||
func (c *Client) ArtistGetInfo(name string) (*Artist, error) {
|
||||
func (c *Client) ArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("format", "json")
|
||||
|
||||
@@ -2,6 +2,7 @@ package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
@@ -25,7 +26,7 @@ var _ = Describe("Client", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
artist, err := client.ArtistGetInfo("U2")
|
||||
artist, err := client.ArtistGetInfo(context.TODO(), "U2")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artist.Name).To(Equal("U2"))
|
||||
Expect(httpClient.savedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||
@@ -37,14 +38,14 @@ var _ = Describe("Client", func() {
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
_, err := client.ArtistGetInfo("U2")
|
||||
_, err := client.ArtistGetInfo(context.TODO(), "U2")
|
||||
Expect(err).To(MatchError("last.fm error(3): Invalid Method - No method with that name in this package"))
|
||||
})
|
||||
|
||||
It("fails if HttpClient.Do() returns error", func() {
|
||||
httpClient.err = errors.New("generic error")
|
||||
|
||||
_, err := client.ArtistGetInfo("U2")
|
||||
_, err := client.ArtistGetInfo(context.TODO(), "U2")
|
||||
Expect(err).To(MatchError("generic error"))
|
||||
})
|
||||
|
||||
@@ -54,7 +55,7 @@ var _ = Describe("Client", func() {
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.ArtistGetInfo("U2")
|
||||
_, err := client.ArtistGetInfo(context.TODO(), "U2")
|
||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -34,8 +35,8 @@ type Client struct {
|
||||
hc HttpClient
|
||||
}
|
||||
|
||||
func (c *Client) ArtistImages(name string) ([]Image, error) {
|
||||
token, err := c.authorize()
|
||||
func (c *Client) ArtistImages(ctx context.Context, name string) ([]Image, error) {
|
||||
token, err := c.authorize(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -58,12 +59,13 @@ func (c *Client) ArtistImages(name string) ([]Image, error) {
|
||||
if len(results.Artists.Items) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
log.Debug(ctx, "Found artist in Spotify", "artist", results.Artists.Items[0].Name)
|
||||
return results.Artists.Items[0].Images, err
|
||||
}
|
||||
|
||||
func (c *Client) authorize() (string, error) {
|
||||
func (c *Client) authorize(ctx context.Context) (string, error) {
|
||||
payload := url.Values{}
|
||||
payload.Add("grant_type", "client_credentials.getInfo")
|
||||
payload.Add("grant_type", "client_credentials")
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(payload.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -80,7 +82,7 @@ func (c *Client) authorize() (string, error) {
|
||||
if v, ok := response["access_token"]; ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
log.Error("Invalid spotify response", "resp", response)
|
||||
log.Error(ctx, "Invalid spotify response", "resp", response)
|
||||
return "", errors.New("invalid response")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package spotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -28,7 +29,7 @@ var _ = Describe("Client", func() {
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
images, err := client.ArtistImages("U2")
|
||||
images, err := client.ArtistImages(context.TODO(), "U2")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(images).To(HaveLen(3))
|
||||
Expect(images[0].Width).To(Equal(640))
|
||||
@@ -50,7 +51,7 @@ var _ = Describe("Client", func() {
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
_, err := client.ArtistImages("U2")
|
||||
_, err := client.ArtistImages(context.TODO(), "U2")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
})
|
||||
|
||||
@@ -62,7 +63,7 @@ var _ = Describe("Client", func() {
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.ArtistImages("U2")
|
||||
_, err := client.ArtistImages(context.TODO(), "U2")
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
})
|
||||
@@ -74,7 +75,7 @@ var _ = Describe("Client", func() {
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
token, err := client.authorize()
|
||||
token, err := client.authorize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
|
||||
auth := httpClient.lastRequest.Header.Get("Authorization")
|
||||
@@ -87,7 +88,7 @@ var _ = Describe("Client", func() {
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize()
|
||||
_, err := client.authorize(nil)
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
|
||||
@@ -97,7 +98,7 @@ var _ = Describe("Client", func() {
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize()
|
||||
_, err := client.authorize(nil)
|
||||
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/navidrome/conf"
|
||||
"github.com/deluan/navidrome/core/lastfm"
|
||||
"github.com/deluan/navidrome/core/spotify"
|
||||
"github.com/deluan/navidrome/core/transcoder"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
@@ -11,5 +16,24 @@ var Set = wire.NewSet(
|
||||
NewTranscodingCache,
|
||||
NewImageCache,
|
||||
NewArchiver,
|
||||
NewExternalInfo,
|
||||
LastFMNewClient,
|
||||
SpotifyNewClient,
|
||||
transcoder.New,
|
||||
)
|
||||
|
||||
func LastFMNewClient() LastFMClient {
|
||||
if conf.Server.LastFM.ApiKey == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastfm.NewClient(conf.Server.LastFM.ApiKey, conf.Server.LastFM.Language, http.DefaultClient)
|
||||
}
|
||||
|
||||
func SpotifyNewClient() SpotifyClient {
|
||||
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return spotify.NewClient(conf.Server.Spotify.ID, conf.Server.Spotify.Secret, http.DefaultClient)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user