Add ExternalInformation core service (not a great name, I know)

This commit is contained in:
Deluan 2020-10-18 19:10:11 -04:00 committed by Deluan Quintão
parent 19ead8f7e8
commit 07535e1518
14 changed files with 313 additions and 38 deletions

View File

@ -50,7 +50,10 @@ func CreateSubsonicAPIRouter() (*subsonic.Router, error) {
mediaStreamer := core.NewMediaStreamer(dataStore, transcoderTranscoder, transcodingCache)
archiver := core.NewArchiver(dataStore)
players := engine.NewPlayers(dataStore)
router := subsonic.New(artwork, listGenerator, playlists, mediaStreamer, archiver, players, dataStore)
lastFMClient := core.LastFMNewClient()
spotifyClient := core.SpotifyNewClient()
externalInfo := core.NewExternalInfo(dataStore, lastFMClient, spotifyClient)
router := subsonic.New(artwork, listGenerator, playlists, mediaStreamer, archiver, players, externalInfo, dataStore)
return router, nil
}

169
core/external_info.go Normal file
View File

@ -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)
}
}
}
}

View File

@ -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")

View File

@ -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"))
})

View File

@ -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")
}

View File

@ -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"))
})
})

View File

@ -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)
}

View File

@ -26,6 +26,7 @@ type ArtistRepository interface {
Exists(id string) (bool, error)
Put(m *Artist) error
Get(id string) (*Artist, error)
FindByName(name string) (*Artist, error)
GetStarred(options ...QueryOptions) (Artists, error)
Search(q string, offset int, size int) (Artists, error)
Refresh(ids ...string) error

11
model/artist_info.go Normal file
View File

@ -0,0 +1,11 @@
package model
type ArtistInfo struct {
ID string
Name string
Bio string
Similar []Artist
SmallImageUrl string
MediumImageUrl string
LargeImageUrl string
}

View File

@ -66,6 +66,18 @@ func (r *artistRepository) Get(id string) (*model.Artist, error) {
return &res[0], nil
}
func (r *artistRepository) FindByName(name string) (*model.Artist, error) {
sel := r.selectArtist().Where(Eq{"name": name})
var res model.Artists
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
if len(res) == 0 {
return nil, model.ErrNotFound
}
return &res[0], nil
}
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
sel := r.selectArtist(options...)
res := model.Artists{}

View File

@ -29,6 +29,7 @@ type Router struct {
Streamer core.MediaStreamer
Archiver core.Archiver
Players engine.Players
ExternalInfo core.ExternalInfo
DataStore model.DataStore
mux http.Handler
@ -36,9 +37,9 @@ type Router struct {
func New(artwork core.Artwork, listGenerator engine.ListGenerator,
playlists engine.Playlists, streamer core.MediaStreamer,
archiver core.Archiver, players engine.Players, ds model.DataStore) *Router {
archiver core.Archiver, players engine.Players, externalInfo core.ExternalInfo, ds model.DataStore) *Router {
r := &Router{Artwork: artwork, ListGenerator: listGenerator, Playlists: playlists,
Streamer: streamer, Archiver: archiver, Players: players, DataStore: ds}
Streamer: streamer, Archiver: archiver, Players: players, ExternalInfo: externalInfo, DataStore: ds}
r.mux = r.routes()
return r
}

View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/core"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/server/subsonic/responses"
@ -17,10 +18,11 @@ import (
type BrowsingController struct {
ds model.DataStore
ei core.ExternalInfo
}
func NewBrowsingController(ds model.DataStore) *BrowsingController {
return &BrowsingController{ds: ds}
func NewBrowsingController(ds model.DataStore, ei core.ExternalInfo) *BrowsingController {
return &BrowsingController{ds: ds, ei: ei}
}
func (c *BrowsingController) GetMusicFolders(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
@ -230,29 +232,75 @@ func (c *BrowsingController) GetGenres(w http.ResponseWriter, r *http.Request) (
return response, nil
}
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"
// TODO Integrate with Last.FM
func (c *BrowsingController) GetArtistInfo(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
id, err := requiredParamString(r, "id", "id parameter required")
if err != nil {
return nil, err
}
count := utils.ParamInt(r, "count", 20)
includeNotPresent := utils.ParamBool(r, "includeNotPresent", false)
entity, err := getEntityByID(ctx, c.ds, id)
if err != nil {
return nil, err
}
switch v := entity.(type) {
case *model.MediaFile:
id = v.ArtistID
case *model.Album:
id = v.AlbumArtistID
case *model.Artist:
id = v.ID
default:
err = model.ErrNotFound
}
if err != nil {
return nil, err
}
info, err := c.ei.ArtistInfo(ctx, id, includeNotPresent, count)
if err != nil {
return nil, err
}
response := newResponse()
response.ArtistInfo = &responses.ArtistInfo{}
response.ArtistInfo.Biography = "Biography not available"
response.ArtistInfo.SmallImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo.MediumImageUrl = placeholderArtistImageMediumUrl
response.ArtistInfo.LargeImageUrl = placeholderArtistImageLargeUrl
response.ArtistInfo.Biography = info.Bio
response.ArtistInfo.SmallImageUrl = info.SmallImageUrl
response.ArtistInfo.MediumImageUrl = info.MediumImageUrl
response.ArtistInfo.LargeImageUrl = info.LargeImageUrl
for _, s := range info.Similar {
similar := responses.Artist{}
similar.Id = s.ID
similar.Name = s.Name
similar.AlbumCount = s.AlbumCount
if s.Starred {
similar.Starred = &s.StarredAt
}
response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar)
}
return response, nil
}
// TODO Integrate with Last.FM
func (c *BrowsingController) GetArtistInfo2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
info, err := c.GetArtistInfo(w, r)
if err != nil {
return nil, err
}
response := newResponse()
response.ArtistInfo2 = &responses.ArtistInfo2{}
response.ArtistInfo2.Biography = "Biography not available"
response.ArtistInfo2.SmallImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo2.MediumImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo2.LargeImageUrl = placeholderArtistImageSmallUrl
response.ArtistInfo2.ArtistInfoBase = info.ArtistInfo.ArtistInfoBase
for _, s := range info.ArtistInfo.SimilarArtist {
similar := responses.ArtistID3{}
similar.Id = s.Id
similar.Name = s.Name
similar.AlbumCount = s.AlbumCount
similar.Starred = s.Starred
response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar)
}
return response, nil
}

View File

@ -19,7 +19,8 @@ func initSystemController(router *Router) *SystemController {
func initBrowsingController(router *Router) *BrowsingController {
dataStore := router.DataStore
browsingController := NewBrowsingController(dataStore)
externalInfo := router.ExternalInfo
browsingController := NewBrowsingController(dataStore, externalInfo)
return browsingController
}
@ -85,5 +86,5 @@ var allProviders = wire.NewSet(
NewUsersController,
NewMediaRetrievalController,
NewStreamController,
NewBookmarksController, engine.NewNowPlayingRepository, wire.FieldsOf(new(*Router), "Artwork", "ListGenerator", "Playlists", "Streamer", "Archiver", "DataStore"),
NewBookmarksController, engine.NewNowPlayingRepository, wire.FieldsOf(new(*Router), "Artwork", "ListGenerator", "Playlists", "Streamer", "Archiver", "DataStore", "ExternalInfo"),
)

View File

@ -19,7 +19,7 @@ var allProviders = wire.NewSet(
NewStreamController,
NewBookmarksController,
engine.NewNowPlayingRepository,
wire.FieldsOf(new(*Router), "Artwork", "ListGenerator", "Playlists", "Streamer", "Archiver", "DataStore"),
wire.FieldsOf(new(*Router), "Artwork", "ListGenerator", "Playlists", "Streamer", "Archiver", "DataStore", "ExternalInfo"),
)
func initSystemController(router *Router) *SystemController {