Get album info (when available) from Last.fm, add getAlbumInfo endpoint (#2061)

* lastfm album.getInfo, getAlbuminfo(2) endpoints

* ... for description and reduce not found log level

* address first comments

* return all images

* Update migration timestamp

* Handle a few edge cases

* Add CoverArtPriority option to retrieve albumart from external sources

* Make agents methods more descriptive

* Use Last.fm name consistently

Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Kendall Garner 2023-01-18 01:22:54 +00:00 committed by GitHub
parent 5564f00838
commit 93adda66d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 797 additions and 188 deletions

View File

@ -243,7 +243,7 @@ func init() {
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata") viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata")
viper.SetDefault("coverartpriority", "embedded, cover.*, folder.*, front.*") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75) viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("enablegravatar", false) viper.SetDefault("enablegravatar", false)

View File

@ -49,6 +49,7 @@ const (
ServerReadHeaderTimeout = 3 * time.Second ServerReadHeaderTimeout = 3 * time.Second
ArtistInfoTimeToLive = 24 * time.Hour ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
I18nFolder = "i18n" I18nFolder = "i18n"
SkipScanFile = ".ndignore" SkipScanFile = ".ndignore"

View File

@ -41,7 +41,7 @@ func (a *Agents) AgentName() string {
return "agents" return "agents"
} }
func (a *Agents) GetMBID(ctx context.Context, id string, name string) (string, error) { func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, ag := range a.agents {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@ -51,7 +51,7 @@ func (a *Agents) GetMBID(ctx context.Context, id string, name string) (string, e
if !ok { if !ok {
continue continue
} }
mbid, err := agent.GetMBID(ctx, id, name) mbid, err := agent.GetArtistMBID(ctx, id, name)
if mbid != "" && err == nil { if mbid != "" && err == nil {
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start)) log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
return mbid, nil return mbid, nil
@ -60,7 +60,7 @@ func (a *Agents) GetMBID(ctx context.Context, id string, name string) (string, e
return "", ErrNotFound return "", ErrNotFound
} }
func (a *Agents) GetURL(ctx context.Context, id, name, mbid string) (string, error) { func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, ag := range a.agents {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@ -70,7 +70,7 @@ func (a *Agents) GetURL(ctx context.Context, id, name, mbid string) (string, err
if !ok { if !ok {
continue continue
} }
url, err := agent.GetURL(ctx, id, name, mbid) url, err := agent.GetArtistURL(ctx, id, name, mbid)
if url != "" && err == nil { if url != "" && err == nil {
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start)) log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
return url, nil return url, nil
@ -79,7 +79,7 @@ func (a *Agents) GetURL(ctx context.Context, id, name, mbid string) (string, err
return "", ErrNotFound return "", ErrNotFound
} }
func (a *Agents) GetBiography(ctx context.Context, id, name, mbid string) (string, error) { func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, ag := range a.agents {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@ -89,7 +89,7 @@ func (a *Agents) GetBiography(ctx context.Context, id, name, mbid string) (strin
if !ok { if !ok {
continue continue
} }
bio, err := agent.GetBiography(ctx, id, name, mbid) bio, err := agent.GetArtistBiography(ctx, id, name, mbid)
if bio != "" && err == nil { if bio != "" && err == nil {
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start)) log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
return bio, nil return bio, nil
@ -98,7 +98,7 @@ func (a *Agents) GetBiography(ctx context.Context, id, name, mbid string) (strin
return "", ErrNotFound return "", ErrNotFound
} }
func (a *Agents) GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) { func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, ag := range a.agents {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@ -108,7 +108,7 @@ func (a *Agents) GetSimilar(ctx context.Context, id, name, mbid string, limit in
if !ok { if !ok {
continue continue
} }
similar, err := agent.GetSimilar(ctx, id, name, mbid, limit) similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
if len(similar) > 0 && err == nil { if len(similar) > 0 && err == nil {
if log.CurrentLevel() >= log.LevelTrace { if log.CurrentLevel() >= log.LevelTrace {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
@ -121,7 +121,7 @@ func (a *Agents) GetSimilar(ctx context.Context, id, name, mbid string, limit in
return nil, ErrNotFound return nil, ErrNotFound
} }
func (a *Agents) GetImages(ctx context.Context, id, name, mbid string) ([]ArtistImage, error) { func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, ag := range a.agents {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@ -131,7 +131,7 @@ func (a *Agents) GetImages(ctx context.Context, id, name, mbid string) ([]Artist
if !ok { if !ok {
continue continue
} }
images, err := agent.GetImages(ctx, id, name, mbid) images, err := agent.GetArtistImages(ctx, id, name, mbid)
if len(images) > 0 && err == nil { if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start)) log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
return images, nil return images, nil
@ -140,7 +140,7 @@ func (a *Agents) GetImages(ctx context.Context, id, name, mbid string) ([]Artist
return nil, ErrNotFound return nil, ErrNotFound
} }
func (a *Agents) GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
start := time.Now() start := time.Now()
for _, ag := range a.agents { for _, ag := range a.agents {
if utils.IsCtxDone(ctx) { if utils.IsCtxDone(ctx) {
@ -150,7 +150,7 @@ func (a *Agents) GetTopSongs(ctx context.Context, id, artistName, mbid string, c
if !ok { if !ok {
continue continue
} }
songs, err := agent.GetTopSongs(ctx, id, artistName, mbid, count) songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
if len(songs) > 0 && err == nil { if len(songs) > 0 && err == nil {
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, nil return songs, nil
@ -159,6 +159,26 @@ func (a *Agents) GetTopSongs(ctx context.Context, id, artistName, mbid string, c
return nil, ErrNotFound return nil, ErrNotFound
} }
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
start := time.Now()
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
agent, ok := ag.(AlbumInfoRetriever)
if !ok {
continue
}
album, err := agent.GetAlbumInfo(ctx, name, artist, mbid)
if err == nil {
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return album, nil
}
}
return nil, ErrNotFound
}
var _ Interface = (*Agents)(nil) var _ Interface = (*Agents)(nil)
var _ ArtistMBIDRetriever = (*Agents)(nil) var _ ArtistMBIDRetriever = (*Agents)(nil)
var _ ArtistURLRetriever = (*Agents)(nil) var _ ArtistURLRetriever = (*Agents)(nil)
@ -166,3 +186,4 @@ var _ ArtistBiographyRetriever = (*Agents)(nil)
var _ ArtistSimilarRetriever = (*Agents)(nil) var _ ArtistSimilarRetriever = (*Agents)(nil)
var _ ArtistImageRetriever = (*Agents)(nil) var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil) var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)

View File

@ -30,9 +30,9 @@ var _ = Describe("Agents", func() {
ag = New(ds) ag = New(ds)
}) })
It("calls the placeholder GetImages", func() { It("calls the placeholder GetArtistImages", func() {
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}}) mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
songs, err := ag.GetTopSongs(ctx, "123", "John Doe", "mb123", 2) songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}})) Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
}) })
@ -56,66 +56,66 @@ var _ = Describe("Agents", func() {
Expect(ag.AgentName()).To(Equal("agents")) Expect(ag.AgentName()).To(Equal("agents"))
}) })
Describe("GetMBID", func() { Describe("GetArtistMBID", func() {
It("returns on first match", func() { It("returns on first match", func() {
Expect(ag.GetMBID(ctx, "123", "test")).To(Equal("mbid")) Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
Expect(mock.Args).To(ConsistOf("123", "test")) Expect(mock.Args).To(ConsistOf("123", "test"))
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetMBID(ctx, "123", "test") _, err := ag.GetArtistMBID(ctx, "123", "test")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test")) Expect(mock.Args).To(ConsistOf("123", "test"))
}) })
It("interrupts if the context is canceled", func() { It("interrupts if the context is canceled", func() {
cancel() cancel()
_, err := ag.GetMBID(ctx, "123", "test") _, err := ag.GetArtistMBID(ctx, "123", "test")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
}) })
Describe("GetURL", func() { Describe("GetArtistURL", func() {
It("returns on first match", func() { It("returns on first match", func() {
Expect(ag.GetURL(ctx, "123", "test", "mb123")).To(Equal("url")) Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetURL(ctx, "123", "test", "mb123") _, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
}) })
It("interrupts if the context is canceled", func() { It("interrupts if the context is canceled", func() {
cancel() cancel()
_, err := ag.GetURL(ctx, "123", "test", "mb123") _, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
}) })
Describe("GetBiography", func() { Describe("GetArtistBiography", func() {
It("returns on first match", func() { It("returns on first match", func() {
Expect(ag.GetBiography(ctx, "123", "test", "mb123")).To(Equal("bio")) Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetBiography(ctx, "123", "test", "mb123") _, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
}) })
It("interrupts if the context is canceled", func() { It("interrupts if the context is canceled", func() {
cancel() cancel()
_, err := ag.GetBiography(ctx, "123", "test", "mb123") _, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
}) })
Describe("GetImages", func() { Describe("GetArtistImages", func() {
It("returns on first match", func() { It("returns on first match", func() {
Expect(ag.GetImages(ctx, "123", "test", "mb123")).To(Equal([]ArtistImage{{ Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
URL: "imageUrl", URL: "imageUrl",
Size: 100, Size: 100,
}})) }}))
@ -123,21 +123,21 @@ var _ = Describe("Agents", func() {
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetImages(ctx, "123", "test", "mb123") _, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
Expect(err).To(MatchError("not found")) Expect(err).To(MatchError("not found"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
}) })
It("interrupts if the context is canceled", func() { It("interrupts if the context is canceled", func() {
cancel() cancel()
_, err := ag.GetImages(ctx, "123", "test", "mb123") _, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
}) })
Describe("GetSimilar", func() { Describe("GetSimilarArtists", func() {
It("returns on first match", func() { It("returns on first match", func() {
Expect(ag.GetSimilar(ctx, "123", "test", "mb123", 1)).To(Equal([]Artist{{ Expect(ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)).To(Equal([]Artist{{
Name: "Joe Dohn", Name: "Joe Dohn",
MBID: "mbid321", MBID: "mbid321",
}})) }}))
@ -145,21 +145,21 @@ var _ = Describe("Agents", func() {
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetSimilar(ctx, "123", "test", "mb123", 1) _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1)) Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
}) })
It("interrupts if the context is canceled", func() { It("interrupts if the context is canceled", func() {
cancel() cancel()
_, err := ag.GetSimilar(ctx, "123", "test", "mb123", 1) _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
}) })
Describe("GetTopSongs", func() { Describe("GetArtistTopSongs", func() {
It("returns on first match", func() { It("returns on first match", func() {
Expect(ag.GetTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{ Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
Name: "A Song", Name: "A Song",
MBID: "mbid444", MBID: "mbid444",
}})) }}))
@ -167,13 +167,49 @@ var _ = Describe("Agents", func() {
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetTopSongs(ctx, "123", "test", "mb123", 2) _, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2)) Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
}) })
It("interrupts if the context is canceled", func() { It("interrupts if the context is canceled", func() {
cancel() cancel()
_, err := ag.GetTopSongs(ctx, "123", "test", "mb123", 2) _, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetAlbumInfo", func() {
It("returns meaningful data", func() {
Expect(ag.GetAlbumInfo(ctx, "album", "artist", "mbid")).To(Equal(&AlbumInfo{
Name: "A Song",
MBID: "mbid444",
Description: "A Description",
URL: "External URL",
Images: []ExternalImage{
{
Size: 174,
URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png",
}, {
Size: 64,
URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png",
}, {
Size: 34,
URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png",
},
},
}))
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
@ -190,7 +226,7 @@ func (a *mockAgent) AgentName() string {
return "fake" return "fake"
} }
func (a *mockAgent) GetMBID(_ context.Context, id string, name string) (string, error) { func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
a.Args = []interface{}{id, name} a.Args = []interface{}{id, name}
if a.Err != nil { if a.Err != nil {
return "", a.Err return "", a.Err
@ -198,7 +234,7 @@ func (a *mockAgent) GetMBID(_ context.Context, id string, name string) (string,
return "mbid", nil return "mbid", nil
} }
func (a *mockAgent) GetURL(_ context.Context, id, name, mbid string) (string, error) { func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []interface{}{id, name, mbid} a.Args = []interface{}{id, name, mbid}
if a.Err != nil { if a.Err != nil {
return "", a.Err return "", a.Err
@ -206,7 +242,7 @@ func (a *mockAgent) GetURL(_ context.Context, id, name, mbid string) (string, er
return "url", nil return "url", nil
} }
func (a *mockAgent) GetBiography(_ context.Context, id, name, mbid string) (string, error) { func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
a.Args = []interface{}{id, name, mbid} a.Args = []interface{}{id, name, mbid}
if a.Err != nil { if a.Err != nil {
return "", a.Err return "", a.Err
@ -214,18 +250,18 @@ func (a *mockAgent) GetBiography(_ context.Context, id, name, mbid string) (stri
return "bio", nil return "bio", nil
} }
func (a *mockAgent) GetImages(_ context.Context, id, name, mbid string) ([]ArtistImage, error) { func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
a.Args = []interface{}{id, name, mbid} a.Args = []interface{}{id, name, mbid}
if a.Err != nil { if a.Err != nil {
return nil, a.Err return nil, a.Err
} }
return []ArtistImage{{ return []ExternalImage{{
URL: "imageUrl", URL: "imageUrl",
Size: 100, Size: 100,
}}, nil }}, nil
} }
func (a *mockAgent) GetSimilar(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) { func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
a.Args = []interface{}{id, name, mbid, limit} a.Args = []interface{}{id, name, mbid, limit}
if a.Err != nil { if a.Err != nil {
return nil, a.Err return nil, a.Err
@ -236,7 +272,7 @@ func (a *mockAgent) GetSimilar(_ context.Context, id, name, mbid string, limit i
}}, nil }}, nil
} }
func (a *mockAgent) GetTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) { func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, artistName, mbid, count} a.Args = []interface{}{id, artistName, mbid, count}
if a.Err != nil { if a.Err != nil {
return nil, a.Err return nil, a.Err
@ -246,3 +282,28 @@ func (a *mockAgent) GetTopSongs(_ context.Context, id, artistName, mbid string,
MBID: "mbid444", MBID: "mbid444",
}}, nil }}, nil
} }
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
a.Args = []interface{}{name, artist, mbid}
if a.Err != nil {
return nil, a.Err
}
return &AlbumInfo{
Name: "A Song",
MBID: "mbid444",
Description: "A Description",
URL: "External URL",
Images: []ExternalImage{
{
Size: 174,
URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png",
}, {
Size: 64,
URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png",
}, {
Size: 34,
URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png",
},
},
}, nil
}

View File

@ -13,12 +13,20 @@ type Interface interface {
AgentName() string AgentName() string
} }
type AlbumInfo struct {
Name string
MBID string
Description string
URL string
Images []ExternalImage
}
type Artist struct { type Artist struct {
Name string Name string
MBID string MBID string
} }
type ArtistImage struct { type ExternalImage struct {
URL string URL string
Size int Size int
} }
@ -32,28 +40,32 @@ var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
) )
type AlbumInfoRetriever interface {
GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error)
}
type ArtistMBIDRetriever interface { type ArtistMBIDRetriever interface {
GetMBID(ctx context.Context, id string, name string) (string, error) GetArtistMBID(ctx context.Context, id string, name string) (string, error)
} }
type ArtistURLRetriever interface { type ArtistURLRetriever interface {
GetURL(ctx context.Context, id, name, mbid string) (string, error) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error)
} }
type ArtistBiographyRetriever interface { type ArtistBiographyRetriever interface {
GetBiography(ctx context.Context, id, name, mbid string) (string, error) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error)
} }
type ArtistSimilarRetriever interface { type ArtistSimilarRetriever interface {
GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error)
} }
type ArtistImageRetriever interface { type ArtistImageRetriever interface {
GetImages(ctx context.Context, id, name, mbid string) ([]ArtistImage, error) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error)
} }
type ArtistTopSongsRetriever interface { type ArtistTopSongsRetriever interface {
GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
} }
var Map map[string]Constructor var Map map[string]Constructor

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"regexp"
"strconv"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
@ -48,7 +50,54 @@ func (l *lastfmAgent) AgentName() string {
return lastFMAgentName return lastFMAgentName
} }
func (l *lastfmAgent) GetMBID(ctx context.Context, id string, name string) (string, error) { var imageRegex = regexp.MustCompile(`u\/(\d+)`)
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
if err != nil {
return nil, err
}
response := agents.AlbumInfo{
Name: a.Name,
MBID: a.MBID,
Description: a.Description.Summary,
URL: a.URL,
Images: make([]agents.ExternalImage, 0),
}
// Last.fm can return duplicate sizes.
seenSizes := map[int]bool{}
// This assumes that Last.fm returns images with size small, medium, and large.
// This is true as of December 29, 2022
for _, img := range a.Image {
size := imageRegex.FindStringSubmatch(img.URL)
// Last.fm can return images without URL
if len(size) == 0 || len(size[0]) < 4 {
log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size)
continue
}
numericSize, err := strconv.Atoi(size[0][2:])
if err != nil {
log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err)
return nil, err
} else {
if _, exists := seenSizes[numericSize]; !exists {
response.Images = append(response.Images, agents.ExternalImage{
Size: numericSize,
URL: img.URL,
})
seenSizes[numericSize] = true
}
}
}
return &response, nil
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, "") a, err := l.callArtistGetInfo(ctx, name, "")
if err != nil { if err != nil {
return "", err return "", err
@ -59,7 +108,7 @@ func (l *lastfmAgent) GetMBID(ctx context.Context, id string, name string) (stri
return a.MBID, nil return a.MBID, nil
} }
func (l *lastfmAgent) GetURL(ctx context.Context, id, name, mbid string) (string, error) { func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, mbid) a, err := l.callArtistGetInfo(ctx, name, mbid)
if err != nil { if err != nil {
return "", err return "", err
@ -70,7 +119,7 @@ func (l *lastfmAgent) GetURL(ctx context.Context, id, name, mbid string) (string
return a.URL, nil return a.URL, nil
} }
func (l *lastfmAgent) GetBiography(ctx context.Context, id, name, mbid string) (string, error) { func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, mbid) a, err := l.callArtistGetInfo(ctx, name, mbid)
if err != nil { if err != nil {
return "", err return "", err
@ -81,7 +130,7 @@ func (l *lastfmAgent) GetBiography(ctx context.Context, id, name, mbid string) (
return a.Bio.Summary, nil return a.Bio.Summary, nil
} }
func (l *lastfmAgent) GetSimilar(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
resp, err := l.callArtistGetSimilar(ctx, name, mbid, limit) resp, err := l.callArtistGetSimilar(ctx, name, mbid, limit)
if err != nil { if err != nil {
return nil, err return nil, err
@ -99,7 +148,7 @@ func (l *lastfmAgent) GetSimilar(ctx context.Context, id, name, mbid string, lim
return res, nil return res, nil
} }
func (l *lastfmAgent) GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callArtistGetTopTracks(ctx, artistName, mbid, count) resp, err := l.callArtistGetTopTracks(ctx, artistName, mbid, count)
if err != nil { if err != nil {
return nil, err return nil, err
@ -117,6 +166,27 @@ func (l *lastfmAgent) GetTopSongs(ctx context.Context, id, artistName, mbid stri
return res, nil return res, nil
} }
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
a, err := l.client.AlbumGetInfo(ctx, name, artist, mbid)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Warn(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "")
}
if err != nil {
if isLastFMError && lfErr.Code == 6 {
log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err)
} else {
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err)
}
return nil, err
}
return a, nil
}
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) { func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
a, err := l.client.ArtistGetInfo(ctx, name, mbid) a, err := l.client.ArtistGetInfo(ctx, name, mbid)
var lfErr *lastFMError var lfErr *lastFMError

View File

@ -43,7 +43,7 @@ var _ = Describe("lastfmAgent", func() {
}) })
}) })
Describe("GetBiography", func() { Describe("GetArtistBiography", func() {
var agent *lastfmAgent var agent *lastfmAgent
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
@ -56,52 +56,52 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() { It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetBiography(ctx, "123", "U2", "mbid-1234")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>")) Expect(agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call fails", func() { It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error") httpClient.Err = errors.New("error")
_, err := agent.GetBiography(ctx, "123", "U2", "mbid-1234") _, err := agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call returns an error", func() { It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetBiography(ctx, "123", "U2", "mbid-1234") _, err := agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call returns an error 6 and mbid is empty", func() { It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetBiography(ctx, "123", "U2", "") _, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
}) })
Context("MBID non existent in Last.FM", func() { Context("MBID non existent in Last.fm", func() {
It("calls again when the response is artist == [unknown]", func() { It("calls again when the response is artist == [unknown]", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.unknown.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, _ = agent.GetBiography(ctx, "123", "U2", "mbid-1234") _, _ = agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(httpClient.RequestCount).To(Equal(2)) Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
}) })
It("calls again when last.fm returns an error 6", func() { It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetBiography(ctx, "123", "U2", "mbid-1234") _, _ = agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(httpClient.RequestCount).To(Equal(2)) Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
}) })
}) })
}) })
Describe("GetSimilar", func() { Describe("GetSimilarArtists", func() {
var agent *lastfmAgent var agent *lastfmAgent
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
@ -114,7 +114,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns similar artists", func() { It("returns similar artists", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Artist{ Expect(agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Artist{
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"}, {Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"}, {Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
})) }))
@ -122,47 +122,47 @@ var _ = Describe("lastfmAgent", func() {
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call fails", func() { It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error") httpClient.Err = errors.New("error")
_, err := agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2) _, err := agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call returns an error", func() { It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2) _, err := agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call returns an error 6 and mbid is empty", func() { It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetSimilar(ctx, "123", "U2", "", 2) _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
}) })
Context("MBID non existent in Last.FM", func() { Context("MBID non existent in Last.fm", func() {
It("calls again when the response is artist == [unknown]", func() { It("calls again when the response is artist == [unknown]", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.unknown.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, _ = agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2) _, _ = agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2)) Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
}) })
It("calls again when last.fm returns an error 6", func() { It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetSimilar(ctx, "123", "U2", "mbid-1234", 2) _, _ = agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2)) Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
}) })
}) })
}) })
Describe("GetTopSongs", func() { Describe("GetArtistTopSongs", func() {
var agent *lastfmAgent var agent *lastfmAgent
var httpClient *tests.FakeHttpClient var httpClient *tests.FakeHttpClient
BeforeEach(func() { BeforeEach(func() {
@ -175,7 +175,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns top songs", func() { It("returns top songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Song{ Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Song{
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"}, {Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"}, {Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
})) }))
@ -183,40 +183,40 @@ var _ = Describe("lastfmAgent", func() {
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call fails", func() { It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error") httpClient.Err = errors.New("error")
_, err := agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2) _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call returns an error", func() { It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2) _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
}) })
It("returns an error if Last.FM call returns an error 6 and mbid is empty", func() { It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetTopSongs(ctx, "123", "U2", "", 2) _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.RequestCount).To(Equal(1))
}) })
Context("MBID non existent in Last.FM", func() { Context("MBID non existent in Last.fm", func() {
It("calls again when the response is artist == [unknown]", func() { It("calls again when the response is artist == [unknown]", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.unknown.json") f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200} httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, _ = agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2) _, _ = agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2)) Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
}) })
It("calls again when last.fm returns an error 6", func() { It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetTopSongs(ctx, "123", "U2", "mbid-1234", 2) _, _ = agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2)) Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
}) })
@ -350,4 +350,89 @@ var _ = Describe("lastfmAgent", func() {
}) })
}) })
Describe("GetAlbumInfo", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := NewClient("API_KEY", "SECRET", "pt", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
URL: "https://www.last.fm/music/Cher/Believe",
Images: []agents.ExternalImage{
{
URL: "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png",
Size: 34,
},
{
URL: "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png",
Size: 64,
},
{
URL: "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png",
Size: 174,
},
{
URL: "https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png",
Size: 300,
},
},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
})
It("returns empty images if no images are available", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
Name: "The Definitive Less Damage And More Joy",
URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
Images: []agents.ExternalImage{},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
Context("MBID non existent in Last.fm", func() {
It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
})
})
}) })

View File

@ -45,6 +45,20 @@ type Client struct {
hc httpDoer hc httpDoer
} }
func (c *Client) AlbumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
params.Add("album", name)
params.Add("artist", artist)
params.Add("mbid", mbid)
params.Add("lang", c.lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
}
return &response.Album, nil
}
func (c *Client) ArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) { func (c *Client) ArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
params := url.Values{} params := url.Values{}
params.Add("method", "artist.getInfo") params.Add("method", "artist.getInfo")

View File

@ -25,6 +25,18 @@ var _ = Describe("Client", func() {
client = NewClient("API_KEY", "SECRET", "pt", httpClient) client = NewClient("API_KEY", "SECRET", "pt", httpClient)
}) })
Describe("AlbumGetInfo", func() {
It("returns an album on successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
album, err := client.AlbumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
})
})
Describe("ArtistGetInfo", func() { Describe("ArtistGetInfo", func() {
It("returns an artist for a successful response", func() { It("returns an artist for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
@ -36,7 +48,7 @@ var _ = Describe("Client", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=123&method=artist.getInfo")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=123&method=artist.getInfo"))
}) })
It("fails if Last.FM returns an http status != 200", func() { It("fails if Last.fm returns an http status != 200", func() {
httpClient.Res = http.Response{ httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
StatusCode: 500, StatusCode: 500,
@ -46,7 +58,7 @@ var _ = Describe("Client", func() {
Expect(err).To(MatchError("last.fm http status: (500)")) Expect(err).To(MatchError("last.fm http status: (500)"))
}) })
It("fails if Last.FM returns an http status != 200", func() { It("fails if Last.fm returns an http status != 200", func() {
httpClient.Res = http.Response{ httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)), Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
StatusCode: 400, StatusCode: 400,
@ -56,7 +68,7 @@ var _ = Describe("Client", func() {
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
}) })
It("fails if Last.FM returns an error", func() { It("fails if Last.fm returns an error", func() {
httpClient.Res = http.Response{ httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)), Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
StatusCode: 200, StatusCode: 200,

View File

@ -4,6 +4,7 @@ type Response struct {
Artist Artist `json:"artist"` Artist Artist `json:"artist"`
SimilarArtists SimilarArtists `json:"similarartists"` SimilarArtists SimilarArtists `json:"similarartists"`
TopTracks TopTracks `json:"toptracks"` TopTracks TopTracks `json:"toptracks"`
Album Album `json:"album"`
Error int `json:"error"` Error int `json:"error"`
Message string `json:"message"` Message string `json:"message"`
Token string `json:"token"` Token string `json:"token"`
@ -12,12 +13,20 @@ type Response struct {
Scrobbles Scrobbles `json:"scrobbles"` Scrobbles Scrobbles `json:"scrobbles"`
} }
type Album struct {
Name string `json:"name"`
MBID string `json:"mbid"`
URL string `json:"url"`
Image []ExternalImage `json:"image"`
Description Description `json:"wiki"`
}
type Artist struct { type Artist struct {
Name string `json:"name"` Name string `json:"name"`
MBID string `json:"mbid"` MBID string `json:"mbid"`
URL string `json:"url"` URL string `json:"url"`
Image []ArtistImage `json:"image"` Image []ExternalImage `json:"image"`
Bio ArtistBio `json:"bio"` Bio Description `json:"bio"`
} }
type SimilarArtists struct { type SimilarArtists struct {
@ -29,12 +38,12 @@ type Attr struct {
Artist string `json:"artist"` Artist string `json:"artist"`
} }
type ArtistImage struct { type ExternalImage struct {
URL string `json:"#text"` URL string `json:"#text"`
Size string `json:"size"` Size string `json:"size"`
} }
type ArtistBio struct { type Description struct {
Published string `json:"published"` Published string `json:"published"`
Summary string `json:"summary"` Summary string `json:"summary"`
Content string `json:"content"` Content string `json:"content"`

View File

@ -21,7 +21,7 @@ func (p *localAgent) AgentName() string {
return LocalAgentName return LocalAgentName
} }
func (p *localAgent) GetTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{ top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Sort: "playCount", Sort: "playCount",
Order: "desc", Order: "desc",

View File

@ -44,7 +44,7 @@ func (s *spotifyAgent) AgentName() string {
return spotifyAgentName return spotifyAgentName
} }
func (s *spotifyAgent) GetImages(ctx context.Context, id, name, mbid string) ([]agents.ArtistImage, error) { func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
a, err := s.searchArtist(ctx, name) a, err := s.searchArtist(ctx, name)
if err != nil { if err != nil {
if errors.Is(err, model.ErrNotFound) { if errors.Is(err, model.ErrNotFound) {
@ -55,9 +55,9 @@ func (s *spotifyAgent) GetImages(ctx context.Context, id, name, mbid string) ([]
return nil, err return nil, err
} }
var res []agents.ArtistImage var res []agents.ExternalImage
for _, img := range a.Images { for _, img := range a.Images {
res = append(res, agents.ArtistImage{ res = append(res, agents.ExternalImage{
URL: img.URL, URL: img.URL,
Size: img.Width, Size: img.Width,
}) })

View File

@ -98,7 +98,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
case model.KindArtistArtwork: case model.KindArtistArtwork:
artReader, err = newArtistReader(ctx, a, artID, a.em) artReader, err = newArtistReader(ctx, a, artID, a.em)
case model.KindAlbumArtwork: case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID) artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
case model.KindMediaFileArtwork: case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID) artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork: case model.KindPlaylistArtwork:

View File

@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
Describe("albumArtworkReader", func() { Describe("albumArtworkReader", func() {
Context("ID not found", func() { Context("ID not found", func() {
It("returns ErrNotFound if album is not in the DB", func() { It("returns ErrNotFound if album is not in the DB", func() {
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT_FOUND")) _, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT_FOUND"), nil)
Expect(err).To(MatchError(model.ErrNotFound)) Expect(err).To(MatchError(model.ErrNotFound))
}) })
}) })
@ -61,7 +61,7 @@ var _ = Describe("Artwork", func() {
}) })
}) })
It("returns embed cover", func() { It("returns embed cover", func() {
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID()) aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx) _, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -69,7 +69,7 @@ var _ = Describe("Artwork", func() {
}) })
It("returns placeholder if embed path is not available", func() { It("returns placeholder if embed path is not available", func() {
ffmpeg.Error = errors.New("not available") ffmpeg.Error = errors.New("not available")
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID()) aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx) _, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -84,14 +84,14 @@ var _ = Describe("Artwork", func() {
}) })
}) })
It("returns external cover", func() { It("returns external cover", func() {
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID()) aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx) _, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("tests/fixtures/front.png")) Expect(path).To(Equal("tests/fixtures/front.png"))
}) })
It("returns placeholder if external file is not available", func() { It("returns placeholder if external file is not available", func() {
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID()) aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx) _, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -107,7 +107,7 @@ var _ = Describe("Artwork", func() {
DescribeTable("CoverArtPriority", DescribeTable("CoverArtPriority",
func(priority string, expected string) { func(priority string, expected string) {
conf.Server.CoverArtPriority = priority conf.Server.CoverArtPriority = priority
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID()) aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx) _, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -122,7 +122,7 @@ var _ = Describe("Artwork", func() {
Describe("mediafileArtworkReader", func() { Describe("mediafileArtworkReader", func() {
Context("ID not found", func() { Context("ID not found", func() {
It("returns ErrNotFound if mediafile is not in the DB", func() { It("returns ErrNotFound if mediafile is not in the DB", func() {
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID()) _, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
Expect(err).To(MatchError(model.ErrNotFound)) Expect(err).To(MatchError(model.ErrNotFound))
}) })
}) })

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
) )
@ -14,16 +15,18 @@ import (
type albumArtworkReader struct { type albumArtworkReader struct {
cacheKey cacheKey
a *artwork a *artwork
em core.ExternalMetadata
album model.Album album model.Album
} }
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*albumArtworkReader, error) { func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*albumArtworkReader, error) {
al, err := artwork.ds.Album(ctx).Get(artID.ID) al, err := artwork.ds.Album(ctx).Get(artID.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
a := &albumArtworkReader{ a := &albumArtworkReader{
a: artwork, a: artwork,
em: em,
album: *al, album: *al,
} }
a.cacheKey.artID = artID a.cacheKey.artID = artID
@ -36,21 +39,22 @@ func (a *albumArtworkReader) LastUpdated() time.Time {
} }
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff = fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority, a.album) var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
ff = append(ff, fromAlbumPlaceholder()) ff = append(ff, fromAlbumPlaceholder())
return selectImageReader(ctx, a.artID, ff...) return selectImageReader(ctx, a.artID, ff...)
} }
func fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string, al model.Album) []sourceFunc { func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc var ff []sourceFunc
for _, pattern := range strings.Split(strings.ToLower(priority), ",") { for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
if pattern == "embedded" { switch {
ff = append(ff, fromTag(al.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, al.EmbedArtPath)) case pattern == "embedded":
continue ff = append(ff, fromTag(a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
} case pattern == "external":
if al.ImageFiles != "" { ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
ff = append(ff, fromExternalFile(ctx, al.ImageFiles, pattern)) case a.album.ImageFiles != "":
ff = append(ff, fromExternalFile(ctx, a.album.ImageFiles, pattern))
} }
} }
return ff return ff

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -81,7 +80,7 @@ func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error
return selectImageReader(ctx, a.artID, return selectImageReader(ctx, a.artID,
fromArtistFolder(ctx, a.artistFolder, "artist.*"), fromArtistFolder(ctx, a.artistFolder, "artist.*"),
fromExternalFile(ctx, a.files, "artist.*"), fromExternalFile(ctx, a.files, "artist.*"),
fromExternalSource(ctx, a.artist, a.em), fromArtistExternalSource(ctx, a.artist, a.em),
fromArtistPlaceholder(), fromArtistPlaceholder(),
) )
} }
@ -106,24 +105,3 @@ func fromArtistFolder(ctx context.Context, artistFolder string, pattern string)
return f, filePath, err return f, filePath, err
} }
} }
func fromExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
return func() (io.ReadCloser, string, error) {
imageUrl, err := em.ArtistImage(ctx, ar.ID)
if err != nil {
return nil, "", err
}
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
resp, err := hc.Do(req)
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("error retrieveing cover from %s: %s", imageUrl, resp.Status)
}
return resp.Body, imageUrl.String(), nil
}
}

View File

@ -5,6 +5,8 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@ -14,6 +16,7 @@ import (
"github.com/dhowden/tag" "github.com/dhowden/tag"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@ -138,3 +141,39 @@ func fromArtistPlaceholder() sourceFunc {
return r, consts.PlaceholderArtistArt, nil return r, consts.PlaceholderArtistArt, nil
} }
} }
func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
return func() (io.ReadCloser, string, error) {
imageUrl, err := em.ArtistImage(ctx, ar.ID)
if err != nil {
return nil, "", err
}
return fromURL(ctx, imageUrl)
}
}
func fromAlbumExternalSource(ctx context.Context, al model.Album, em core.ExternalMetadata) sourceFunc {
return func() (io.ReadCloser, string, error) {
imageUrl, err := em.AlbumImage(ctx, al.ID)
if err != nil {
return nil, "", err
}
return fromURL(ctx, imageUrl)
}
}
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
resp, err := hc.Do(req)
if err != nil {
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("error retrieveing artwork from %s: %s", imageUrl, resp.Status)
}
return resp.Body, imageUrl.String(), nil
}

View File

@ -28,10 +28,12 @@ const (
) )
type ExternalMetadata interface { type ExternalMetadata interface {
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
ArtistImage(ctx context.Context, id string) (*url.URL, error) ArtistImage(ctx context.Context, id string) (*url.URL, error)
AlbumImage(ctx context.Context, id string) (*url.URL, error)
} }
type externalMetadata struct { type externalMetadata struct {
@ -39,6 +41,11 @@ type externalMetadata struct {
ag *agents.Agents ag *agents.Agents
} }
type auxAlbum struct {
model.Album
Name string
}
type auxArtist struct { type auxArtist struct {
model.Artist model.Artist
Name string Name string
@ -48,6 +55,97 @@ func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMeta
return &externalMetadata{ds: ds, ag: agents} return &externalMetadata{ds: ds, ag: agents}
} }
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum, error) {
var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return nil, err
}
var album auxAlbum
switch v := entity.(type) {
case *model.Album:
album.Album = *v
album.Name = clearName(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
return nil, model.ErrNotFound
}
return &album, nil
}
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
album, err := e.getAlbum(ctx, id)
if err != nil {
log.Info(ctx, "Not found", "id", id)
return nil, err
}
if album.ExternalInfoUpdatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
err = e.refreshAlbumInfo(ctx, album)
if err != nil {
return nil, err
}
}
if time.Since(album.ExternalInfoUpdatedAt) > consts.AlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
err := e.refreshAlbumInfo(ctx, album)
if err != nil {
log.Error("Error refreshing AlbumInfo", "id", id, "name", album.Name, err)
}
}()
}
return &album.Album, nil
}
func (e *externalMetadata) refreshAlbumInfo(ctx context.Context, album *auxAlbum) error {
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return nil
}
if err != nil {
return err
}
album.ExternalInfoUpdatedAt = time.Now()
album.ExternalUrl = info.URL
if info.Description != "" {
album.Description = info.Description
}
if len(info.Images) > 0 {
sort.Slice(info.Images, func(i, j int) bool {
return info.Images[i].Size > info.Images[j].Size
})
album.LargeImageUrl = info.Images[0].URL
if len(info.Images) >= 2 {
album.MediumImageUrl = info.Images[1].URL
}
if len(info.Images) >= 3 {
album.SmallImageUrl = info.Images[2].URL
}
}
err = e.ds.Album(ctx).Put(&album.Album)
if err != nil {
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name, err)
}
log.Trace(ctx, "AlbumInfo collected", "album", album)
return nil
}
func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) { func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) {
var entity interface{} var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id) entity, err := model.GetEntityByID(ctx, e.ds, id)
@ -116,7 +214,7 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArtist) error { func (e *externalMetadata) refreshArtistInfo(ctx context.Context, artist *auxArtist) error {
// Get MBID first, if it is not yet available // Get MBID first, if it is not yet available
if artist.MbzArtistID == "" { if artist.MbzArtistID == "" {
mbid, err := e.ag.GetMBID(ctx, artist.ID, artist.Name) mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
if mbid != "" && err == nil { if mbid != "" && err == nil {
artist.MbzArtistID = mbid artist.MbzArtistID = mbid
} }
@ -234,6 +332,34 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
return url.Parse(imageUrl) return url.Parse(imageUrl)
} }
func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
album, err := e.getAlbum(ctx, id)
if err != nil {
return nil, err
}
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return nil, err
}
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "AlbumImage call canceled", ctx.Err())
return nil, ctx.Err()
}
// Return the biggest image
var img agents.ExternalImage
for _, i := range info.Images {
if img.Size <= i.Size {
img = i
}
}
if img.URL == "" {
return nil, agents.ErrNotFound
}
return url.Parse(img.URL)
}
func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
artist, err := e.findArtistByName(ctx, artistName) artist, err := e.findArtistByName(ctx, artistName)
if err != nil { if err != nil {
@ -245,7 +371,7 @@ func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, coun
} }
func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
songs, err := agent.GetTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count) songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
if errors.Is(err, agents.ErrNotFound) { if errors.Is(err, agents.ErrNotFound) {
return nil, nil return nil, nil
} }
@ -300,7 +426,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
} }
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
url, err := agent.GetURL(ctx, artist.ID, artist.Name, artist.MbzArtistID) url, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
if url == "" || err != nil { if url == "" || err != nil {
return return
} }
@ -308,7 +434,7 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
} }
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
bio, err := agent.GetBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID) bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
if bio == "" || err != nil { if bio == "" || err != nil {
return return
} }
@ -318,7 +444,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
} }
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) { func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
images, err := agent.GetImages(ctx, artist.ID, artist.Name, artist.MbzArtistID) images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
if len(images) == 0 || err != nil { if len(images) == 0 || err != nil {
return return
} }
@ -337,7 +463,7 @@ func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.Artist
func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) { limit int, includeNotPresent bool) {
similar, err := agent.GetSimilar(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit) similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil { if len(similar) == 0 || err != nil {
return return
} }

View File

@ -0,0 +1,33 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddAlbumInfo, downAddAlbumInfo)
}
func upAddAlbumInfo(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add description varchar(255) default '' not null;
alter table album
add small_image_url varchar(255) default '' not null;
alter table album
add medium_image_url varchar(255) default '' not null;
alter table album
add large_image_url varchar(255) default '' not null;
alter table album
add external_url varchar(255) default '' not null;
alter table album
add external_info_updated_at datetime;
`)
return err
}
func downAddAlbumInfo(tx *sql.Tx) error {
return nil
}

View File

@ -10,38 +10,44 @@ import (
type Album struct { type Album struct {
Annotations `structs:"-"` Annotations `structs:"-"`
ID string `structs:"id" json:"id" orm:"column(id)"` ID string `structs:"id" json:"id" orm:"column(id)"`
Name string `structs:"name" json:"name"` Name string `structs:"name" json:"name"`
EmbedArtPath string `structs:"embed_art_path" json:"embedArtPath"` EmbedArtPath string `structs:"embed_art_path" json:"embedArtPath"`
ArtistID string `structs:"artist_id" json:"artistId" orm:"column(artist_id)"` ArtistID string `structs:"artist_id" json:"artistId" orm:"column(artist_id)"`
Artist string `structs:"artist" json:"artist"` Artist string `structs:"artist" json:"artist"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"column(album_artist_id)"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"column(album_artist_id)"`
AlbumArtist string `structs:"album_artist" json:"albumArtist"` AlbumArtist string `structs:"album_artist" json:"albumArtist"`
AllArtistIDs string `structs:"all_artist_ids" json:"allArtistIds" orm:"column(all_artist_ids)"` AllArtistIDs string `structs:"all_artist_ids" json:"allArtistIds" orm:"column(all_artist_ids)"`
MaxYear int `structs:"max_year" json:"maxYear"` MaxYear int `structs:"max_year" json:"maxYear"`
MinYear int `structs:"min_year" json:"minYear"` MinYear int `structs:"min_year" json:"minYear"`
Compilation bool `structs:"compilation" json:"compilation"` Compilation bool `structs:"compilation" json:"compilation"`
Comment string `structs:"comment" json:"comment,omitempty"` Comment string `structs:"comment" json:"comment,omitempty"`
SongCount int `structs:"song_count" json:"songCount"` SongCount int `structs:"song_count" json:"songCount"`
Duration float32 `structs:"duration" json:"duration"` Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"` Size int64 `structs:"size" json:"size"`
Genre string `structs:"genre" json:"genre"` Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres"` Genres Genres `structs:"-" json:"genres"`
FullText string `structs:"full_text" json:"fullText"` FullText string `structs:"full_text" json:"fullText"`
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"` MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"`
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"` MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"`
MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"`
MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"`
ImageFiles string `structs:"image_files" json:"imageFiles,omitempty"` ImageFiles string `structs:"image_files" json:"imageFiles,omitempty"`
Paths string `structs:"paths" json:"paths,omitempty"` Paths string `structs:"paths" json:"paths,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"` Description string `structs:"description" json:"description,omitempty"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty"`
MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty"`
LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty"`
ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" orm:"column(external_url)"`
ExternalInfoUpdatedAt time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
} }
func (a Album) CoverArtID() ArtworkID { func (a Album) CoverArtID() ArtworkID {

View File

@ -92,9 +92,9 @@ func checkFfmpegInstallation() {
func checkExternalCredentials() { func checkExternalCredentials() {
if conf.Server.EnableExternalServices { if conf.Server.EnableExternalServices {
if !conf.Server.LastFM.Enabled { if !conf.Server.LastFM.Enabled {
log.Info("Last.FM integration is DISABLED") log.Info("Last.fm integration is DISABLED")
} else { } else {
log.Debug("Last.FM integration is ENABLED") log.Debug("Last.fm integration is ENABLED")
} }
if !conf.Server.ListenBrainz.Enabled { if !conf.Server.ListenBrainz.Enabled {

View File

@ -83,6 +83,8 @@ func (api *Router) routes() http.Handler {
h(r, "getArtist", api.GetArtist) h(r, "getArtist", api.GetArtist)
h(r, "getAlbum", api.GetAlbum) h(r, "getAlbum", api.GetAlbum)
h(r, "getSong", api.GetSong) h(r, "getSong", api.GetSong)
h(r, "getAlbumInfo", api.GetAlbumInfo)
h(r, "getAlbumInfo2", api.GetAlbumInfo)
h(r, "getArtistInfo", api.GetArtistInfo) h(r, "getArtistInfo", api.GetArtistInfo)
h(r, "getArtistInfo2", api.GetArtistInfo2) h(r, "getArtistInfo2", api.GetArtistInfo2)
h(r, "getTopSongs", api.GetTopSongs) h(r, "getTopSongs", api.GetTopSongs)
@ -162,7 +164,6 @@ func (api *Router) routes() http.Handler {
// Not Implemented (yet?) // Not Implemented (yet?)
h501(r, "jukeboxControl") h501(r, "jukeboxControl")
h501(r, "getAlbumInfo", "getAlbumInfo2")
h501(r, "getShares", "createShare", "updateShare", "deleteShare") h501(r, "getShares", "createShare", "updateShare", "deleteShare")
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel", h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
"deletePodcastEpisode", "downloadPodcastEpisode") "deletePodcastEpisode", "downloadPodcastEpisode")

View File

@ -154,6 +154,7 @@ func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) {
func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) {
id := utils.ParamString(r, "id") id := utils.ParamString(r, "id")
ctx := r.Context() ctx := r.Context()
album, err := api.ds.Album(ctx).Get(id) album, err := api.ds.Album(ctx).Get(id)
@ -177,6 +178,32 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) {
return response, nil return response, nil
} }
func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
id, err := requiredParamString(r, "id")
ctx := r.Context()
if err != nil {
return nil, err
}
album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id)
if err != nil {
return nil, err
}
response := newResponse()
response.AlbumInfo = &responses.AlbumInfo{}
response.AlbumInfo.Notes = album.Description
response.AlbumInfo.SmallImageUrl = album.SmallImageUrl
response.AlbumInfo.MediumImageUrl = album.MediumImageUrl
response.AlbumInfo.LargeImageUrl = album.LargeImageUrl
response.AlbumInfo.LastFmUrl = album.ExternalUrl
response.AlbumInfo.MusicBrainzID = album.MbzAlbumID
return response, nil
}
func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
id := utils.ParamString(r, "id") id := utils.ParamString(r, "id")
ctx := r.Context() ctx := r.Context()
@ -397,7 +424,6 @@ func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model
if album.Starred { if album.Starred {
dir.Starred = &album.StarredAt dir.Starred = &album.StarredAt
} }
dir.Song = childrenFromMediaFiles(ctx, mfs) dir.Song = childrenFromMediaFiles(ctx, mfs)
return dir return dir
} }

View File

@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","albumInfo":{"notes":"Believe is the twenty-third studio album by American singer-actress Cher...","musicBrainzId":"03c91c40-49a6-44a7-90e7-a700edf97a62","lastFmUrl":"https://www.last.fm/music/Cher/Believe","smallImageUrl":"https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png","mediumImageUrl":"https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png","largeImageUrl":"https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png"}}

View File

@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><albumInfo><notes>Believe is the twenty-third studio album by American singer-actress Cher...</notes><musicBrainzId>03c91c40-49a6-44a7-90e7-a700edf97a62</musicBrainzId><lastFmUrl>https://www.last.fm/music/Cher/Believe</lastFmUrl><smallImageUrl>https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png</smallImageUrl><mediumImageUrl>https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png</mediumImageUrl><largeImageUrl>https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png</largeImageUrl></albumInfo></subsonic-response>

View File

@ -0,0 +1 @@
{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","albumInfo":{}}

View File

@ -0,0 +1 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0"><albumInfo></albumInfo></subsonic-response>

View File

@ -37,6 +37,7 @@ type Subsonic struct {
ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"` ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"` AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
AlbumInfo *AlbumInfo `xml:"albumInfo,omitempty" json:"albumInfo,omitempty"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"` ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"` ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
SimilarSongs *SimilarSongs `xml:"similarSongs,omitempty" json:"similarSongs,omitempty"` SimilarSongs *SimilarSongs `xml:"similarSongs,omitempty" json:"similarSongs,omitempty"`
@ -296,6 +297,15 @@ type Genres struct {
Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"` Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"`
} }
type AlbumInfo struct {
Notes string `xml:"notes,omitempty" json:"notes,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfoBase struct { type ArtistInfoBase struct {
Biography string `xml:"biography,omitempty" json:"biography,omitempty"` Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"` MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`

View File

@ -335,6 +335,39 @@ var _ = Describe("Responses", func() {
}) })
}) })
Describe("AlbumInfo", func() {
BeforeEach(func() {
response.AlbumInfo = &AlbumInfo{}
})
Context("without data", func() {
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
Context("with data", func() {
BeforeEach(func() {
response.AlbumInfo.SmallImageUrl = "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png"
response.AlbumInfo.MediumImageUrl = "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png"
response.AlbumInfo.LargeImageUrl = "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png"
response.AlbumInfo.LastFmUrl = "https://www.last.fm/music/Cher/Believe"
response.AlbumInfo.MusicBrainzID = "03c91c40-49a6-44a7-90e7-a700edf97a62"
response.AlbumInfo.Notes = "Believe is the twenty-third studio album by American singer-actress Cher..."
})
It("should match .XML", func() {
Expect(xml.Marshal(response)).To(MatchSnapshot())
})
It("should match .JSON", func() {
Expect(json.Marshal(response)).To(MatchSnapshot())
})
})
})
Describe("ArtistInfo", func() { Describe("ArtistInfo", func() {
BeforeEach(func() { BeforeEach(func() {
response.ArtistInfo = &ArtistInfo{} response.ArtistInfo = &ArtistInfo{}

View File

@ -0,0 +1 @@
{"album":{"artist":"The Jesus and Mary Chain","listeners":"2","image":[{"size":"small","#text":""},{"size":"medium","#text":""},{"size":"large","#text":""},{"size":"extralarge","#text":""},{"size":"mega","#text":""},{"size":"","#text":""}],"mbid":"","tags":"","name":"The Definitive Less Damage And More Joy","playcount":"2","url":"https:\/\/www.last.fm\/music\/The+Jesus+and+Mary+Chain\/The+Definitive+Less+Damage+And+More+Joy"}}

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import React, { useMemo, useCallback } from 'react' import React, { useMemo, useCallback, useState, useEffect } from 'react'
import { import {
Card, Card,
CardContent, CardContent,
@ -91,6 +91,13 @@ const useStyles = makeStyles(
float: 'left', float: 'left',
wordBreak: 'break-word', wordBreak: 'break-word',
}, },
notes: {
display: 'inline-block',
marginTop: '1em',
float: 'left',
wordBreak: 'break-word',
cursor: 'pointer',
},
pointerCursor: { pointerCursor: {
cursor: 'pointer', cursor: 'pointer',
}, },
@ -211,6 +218,29 @@ const AlbumDetails = (props) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const classes = useStyles() const classes = useStyles()
const [isLightboxOpen, setLightboxOpen] = React.useState(false) const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState()
let notes =
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
if (notes !== undefined) {
notes += '..'
}
useEffect(() => {
subsonic
.getAlbumInfo(record.id)
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
setAlbumInfo(data.albumInfo)
}
})
.catch((e) => {
console.error('error on album page', e)
})
}, [record])
const imageUrl = subsonic.getCoverArtUrl(record, 300) const imageUrl = subsonic.getCoverArtUrl(record, 300)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
@ -277,11 +307,38 @@ const AlbumDetails = (props) => {
<AlbumExternalLinks className={classes.externalLinks} /> <AlbumExternalLinks className={classes.externalLinks} />
</Typography> </Typography>
)} )}
{isDesktop && (
<Collapse
collapsedHeight={'2.75em'}
in={expanded}
timeout={'auto'}
className={classes.notes}
>
<Typography
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: notes }} />
</Typography>
</Collapse>
)}
{isDesktop && record['comment'] && <AlbumComment record={record} />} {isDesktop && record['comment'] && <AlbumComment record={record} />}
</CardContent> </CardContent>
</div> </div>
</div> </div>
{!isDesktop && record['comment'] && <AlbumComment record={record} />} {!isDesktop && record['comment'] && <AlbumComment record={record} />}
{!isDesktop && (
<div className={classes.notes}>
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
<Typography
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: notes }} />
</Typography>
</Collapse>
</div>
)}
{isLightboxOpen && ( {isLightboxOpen && (
<Lightbox <Lightbox
imagePadding={50} imagePadding={50}

View File

@ -63,6 +63,10 @@ const getArtistInfo = (id) => {
return httpClient(url('getArtistInfo', id)) return httpClient(url('getArtistInfo', id))
} }
const getAlbumInfo = (id) => {
return httpClient(url('getAlbumInfo', id))
}
const streamUrl = (id) => { const streamUrl = (id) => {
return baseUrl(url('stream', id, { ts: true })) return baseUrl(url('stream', id, { ts: true }))
} }
@ -79,5 +83,6 @@ export default {
getScanStatus, getScanStatus,
getCoverArtUrl, getCoverArtUrl,
streamUrl, streamUrl,
getAlbumInfo,
getArtistInfo, getArtistInfo,
} }