update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
12
core/agents/README.md
Normal file
12
core/agents/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
This folder abstracts metadata lookup into "agents". Each agent can be implemented to get as
|
||||
much info as the external source provides, by using a granular set of interfaces
|
||||
(see [interfaces](interfaces.go)).
|
||||
|
||||
A new agent must comply with these simple implementation rules:
|
||||
1) Implement the `AgentName()` method. It just returns the name of the agent for logging purposes.
|
||||
2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides.
|
||||
3) Register itself (in its `init()` function).
|
||||
|
||||
For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents
|
||||
|
||||
For a simple Agent example, look at the [local_agent](local_agent.go) agent source code.
|
||||
374
core/agents/agents.go
Normal file
374
core/agents/agents.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
// PluginLoader defines an interface for loading plugins
|
||||
type PluginLoader interface {
|
||||
// PluginNames returns the names of all plugins that implement a particular service
|
||||
PluginNames(capability string) []string
|
||||
// LoadMediaAgent loads and returns a media agent plugin
|
||||
LoadMediaAgent(name string) (Interface, bool)
|
||||
}
|
||||
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
}
|
||||
|
||||
// GetAgents returns the singleton instance of Agents
|
||||
func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
||||
return singleton.GetInstance(func() *Agents {
|
||||
return createAgents(ds, pluginLoader)
|
||||
})
|
||||
}
|
||||
|
||||
// createAgents creates a new Agents instance. Used in tests
|
||||
func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
||||
return &Agents{
|
||||
ds: ds,
|
||||
pluginLoader: pluginLoader,
|
||||
}
|
||||
}
|
||||
|
||||
// enabledAgent represents an enabled agent with its type information
|
||||
type enabledAgent struct {
|
||||
name string
|
||||
isPlugin bool
|
||||
}
|
||||
|
||||
// getEnabledAgentNames returns the current list of enabled agents, including:
|
||||
// 1. Built-in agents and plugins from config (in the specified order)
|
||||
// 2. Always include LocalAgentName
|
||||
// 3. If config is empty, include ONLY LocalAgentName
|
||||
// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
|
||||
func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||
// If no agents configured, ONLY use the local agent
|
||||
if conf.Server.Agents == "" {
|
||||
return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
|
||||
}
|
||||
|
||||
// Get all available plugin names
|
||||
var availablePlugins []string
|
||||
if a.pluginLoader != nil {
|
||||
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
||||
}
|
||||
|
||||
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||
|
||||
// Always add LocalAgentName if not already included
|
||||
hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
|
||||
if !hasLocalAgent {
|
||||
configuredAgents = append(configuredAgents, LocalAgentName)
|
||||
}
|
||||
|
||||
// Filter to only include valid agents (built-in or plugins)
|
||||
var validAgents []enabledAgent
|
||||
for _, name := range configuredAgents {
|
||||
// Check if it's a built-in agent
|
||||
isBuiltIn := Map[name] != nil
|
||||
|
||||
// Check if it's a plugin
|
||||
isPlugin := slices.Contains(availablePlugins, name)
|
||||
|
||||
if isBuiltIn {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: false})
|
||||
} else if isPlugin {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
||||
} else {
|
||||
log.Debug("Unknown agent ignored", "name", name)
|
||||
}
|
||||
}
|
||||
return validAgents
|
||||
}
|
||||
|
||||
func (a *Agents) getAgent(ea enabledAgent) Interface {
|
||||
if ea.isPlugin {
|
||||
// Try to load WASM plugin agent (if plugin loader is available)
|
||||
if a.pluginLoader != nil {
|
||||
agent, ok := a.pluginLoader.LoadMediaAgent(ea.name)
|
||||
if ok && agent != nil {
|
||||
return agent
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try to get built-in agent
|
||||
constructor, ok := Map[ea.name]
|
||||
if ok {
|
||||
agent := constructor(a.ds)
|
||||
if agent != nil {
|
||||
return agent
|
||||
}
|
||||
log.Debug("Built-in agent not available. Missing configuration?", "name", ea.name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agents) AgentName() string {
|
||||
return "agents"
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mbid, err := retriever.GetArtistMBID(ctx, id, name)
|
||||
if mbid != "" && err == nil {
|
||||
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
return mbid, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
if url != "" && err == nil {
|
||||
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return "", ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
||||
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
||||
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(ArtistSimilarRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
} else {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
|
||||
}
|
||||
return similar, err
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
|
||||
return images, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
||||
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
||||
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
||||
if len(songs) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||
if name == consts.UnknownAlbum {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(AlbumInfoRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
album, err := retriever.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
|
||||
}
|
||||
|
||||
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
|
||||
if name == consts.UnknownAlbum {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(AlbumImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||
"mbid", mbid, "elapsed", time.Since(start))
|
||||
return images, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var _ Interface = (*Agents)(nil)
|
||||
var _ ArtistMBIDRetriever = (*Agents)(nil)
|
||||
var _ ArtistURLRetriever = (*Agents)(nil)
|
||||
var _ ArtistBiographyRetriever = (*Agents)(nil)
|
||||
var _ ArtistSimilarRetriever = (*Agents)(nil)
|
||||
var _ ArtistImageRetriever = (*Agents)(nil)
|
||||
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
||||
var _ AlbumInfoRetriever = (*Agents)(nil)
|
||||
var _ AlbumImageRetriever = (*Agents)(nil)
|
||||
281
core/agents/agents_plugin_test.go
Normal file
281
core/agents/agents_plugin_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// MockPluginLoader implements PluginLoader for testing
|
||||
type MockPluginLoader struct {
|
||||
pluginNames []string
|
||||
loadedAgents map[string]*MockAgent
|
||||
pluginCallCount map[string]int
|
||||
}
|
||||
|
||||
func NewMockPluginLoader() *MockPluginLoader {
|
||||
return &MockPluginLoader{
|
||||
pluginNames: []string{},
|
||||
loadedAgents: make(map[string]*MockAgent),
|
||||
pluginCallCount: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockPluginLoader) PluginNames(serviceName string) []string {
|
||||
return m.pluginNames
|
||||
}
|
||||
|
||||
func (m *MockPluginLoader) LoadMediaAgent(name string) (Interface, bool) {
|
||||
m.pluginCallCount[name]++
|
||||
agent, exists := m.loadedAgents[name]
|
||||
return agent, exists
|
||||
}
|
||||
|
||||
// MockAgent is a mock agent implementation for testing
|
||||
type MockAgent struct {
|
||||
name string
|
||||
mbid string
|
||||
}
|
||||
|
||||
func (m *MockAgent) AgentName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MockAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
return m.mbid, nil
|
||||
}
|
||||
|
||||
var _ Interface = (*MockAgent)(nil)
|
||||
var _ ArtistMBIDRetriever = (*MockAgent)(nil)
|
||||
|
||||
var _ PluginLoader = (*MockPluginLoader)(nil)
|
||||
|
||||
var _ = Describe("Agents with Plugin Loading", func() {
|
||||
var mockLoader *MockPluginLoader
|
||||
var agents *Agents
|
||||
|
||||
BeforeEach(func() {
|
||||
mockLoader = NewMockPluginLoader()
|
||||
|
||||
// Create the agents instance with our mock loader
|
||||
agents = createAgents(nil, mockLoader)
|
||||
})
|
||||
|
||||
Context("Dynamic agent discovery", func() {
|
||||
It("should include ONLY local agent when no config is specified", func() {
|
||||
// Ensure no specific agents are configured
|
||||
conf.Server.Agents = ""
|
||||
|
||||
// Add some plugin agents that should be ignored
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
|
||||
|
||||
// Should only include the local agent
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
Expect(enabledAgents).To(HaveLen(1))
|
||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||
Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin
|
||||
})
|
||||
|
||||
It("should NOT include plugin agents when no config is specified", func() {
|
||||
// Ensure no specific agents are configured
|
||||
conf.Server.Agents = ""
|
||||
|
||||
// Add a plugin agent
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||
|
||||
// Should only include the local agent
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
Expect(enabledAgents).To(HaveLen(1))
|
||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||
Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin
|
||||
})
|
||||
|
||||
It("should include plugin agents in the enabled agents list ONLY when explicitly configured", func() {
|
||||
// Add a plugin agent
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||
|
||||
// With no config, should not include plugin
|
||||
conf.Server.Agents = ""
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
Expect(enabledAgents).To(HaveLen(1))
|
||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||
|
||||
// When explicitly configured, should include plugin
|
||||
conf.Server.Agents = "plugin_agent"
|
||||
enabledAgents = agents.getEnabledAgentNames()
|
||||
var agentNames []string
|
||||
var pluginAgentFound bool
|
||||
for _, agent := range enabledAgents {
|
||||
agentNames = append(agentNames, agent.name)
|
||||
if agent.name == "plugin_agent" {
|
||||
pluginAgentFound = true
|
||||
Expect(agent.isPlugin).To(BeTrue()) // plugin_agent is a plugin
|
||||
}
|
||||
}
|
||||
Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_agent"))
|
||||
Expect(pluginAgentFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should only include configured plugin agents when config is specified", func() {
|
||||
// Add two plugin agents
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
|
||||
|
||||
// Configure only one of them
|
||||
conf.Server.Agents = "plugin_one"
|
||||
|
||||
// Verify only the configured one is included
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
var agentNames []string
|
||||
var pluginOneFound bool
|
||||
for _, agent := range enabledAgents {
|
||||
agentNames = append(agentNames, agent.name)
|
||||
if agent.name == "plugin_one" {
|
||||
pluginOneFound = true
|
||||
Expect(agent.isPlugin).To(BeTrue()) // plugin_one is a plugin
|
||||
}
|
||||
}
|
||||
Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_one"))
|
||||
Expect(agentNames).NotTo(ContainElement("plugin_two"))
|
||||
Expect(pluginOneFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should load plugin agents on demand", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Configure to use our plugin
|
||||
conf.Server.Agents = "plugin_agent"
|
||||
|
||||
// Add a plugin agent
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||
mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
|
||||
name: "plugin_agent",
|
||||
mbid: "plugin-mbid",
|
||||
}
|
||||
|
||||
// Try to get data from it
|
||||
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(Equal("plugin-mbid"))
|
||||
Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1))
|
||||
})
|
||||
|
||||
It("should try both built-in and plugin agents", func() {
|
||||
// Create a mock built-in agent
|
||||
Register("built_in", func(ds model.DataStore) Interface {
|
||||
return &MockAgent{
|
||||
name: "built_in",
|
||||
mbid: "built-in-mbid",
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
delete(Map, "built_in")
|
||||
}()
|
||||
|
||||
// Configure to use both built-in and plugin
|
||||
conf.Server.Agents = "built_in,plugin_agent"
|
||||
|
||||
// Add a plugin agent
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||
mockLoader.loadedAgents["plugin_agent"] = &MockAgent{
|
||||
name: "plugin_agent",
|
||||
mbid: "plugin-mbid",
|
||||
}
|
||||
|
||||
// Verify that both are in the enabled list
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
var agentNames []string
|
||||
var builtInFound, pluginFound bool
|
||||
for _, agent := range enabledAgents {
|
||||
agentNames = append(agentNames, agent.name)
|
||||
if agent.name == "built_in" {
|
||||
builtInFound = true
|
||||
Expect(agent.isPlugin).To(BeFalse()) // built-in agent
|
||||
}
|
||||
if agent.name == "plugin_agent" {
|
||||
pluginFound = true
|
||||
Expect(agent.isPlugin).To(BeTrue()) // plugin agent
|
||||
}
|
||||
}
|
||||
Expect(agentNames).To(ContainElements("built_in", "plugin_agent", LocalAgentName))
|
||||
Expect(builtInFound).To(BeTrue())
|
||||
Expect(pluginFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should respect the order specified in configuration", func() {
|
||||
// Create mock built-in agents
|
||||
Register("agent_a", func(ds model.DataStore) Interface {
|
||||
return &MockAgent{name: "agent_a"}
|
||||
})
|
||||
Register("agent_b", func(ds model.DataStore) Interface {
|
||||
return &MockAgent{name: "agent_b"}
|
||||
})
|
||||
defer func() {
|
||||
delete(Map, "agent_a")
|
||||
delete(Map, "agent_b")
|
||||
}()
|
||||
|
||||
// Add plugin agents
|
||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
|
||||
|
||||
// Configure specific order - plugin first, then built-ins
|
||||
conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
|
||||
|
||||
// Get the agent names
|
||||
enabledAgents := agents.getEnabledAgentNames()
|
||||
|
||||
// Extract just the names to verify the order
|
||||
agentNames := slice.Map(enabledAgents, func(a enabledAgent) string { return a.name })
|
||||
|
||||
// Verify the order matches configuration, with LocalAgentName at the end
|
||||
Expect(agentNames).To(HaveExactElements("plugin_y", "agent_b", "plugin_x", "agent_a", LocalAgentName))
|
||||
})
|
||||
|
||||
It("should NOT call LoadMediaAgent for built-in agents", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock built-in agent
|
||||
Register("builtin_agent", func(ds model.DataStore) Interface {
|
||||
return &MockAgent{
|
||||
name: "builtin_agent",
|
||||
mbid: "builtin-mbid",
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
delete(Map, "builtin_agent")
|
||||
}()
|
||||
|
||||
// Configure to use only built-in agents
|
||||
conf.Server.Agents = "builtin_agent"
|
||||
|
||||
// Call GetArtistMBID which should only use the built-in agent
|
||||
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(Equal("builtin-mbid"))
|
||||
|
||||
// Verify LoadMediaAgent was NEVER called (no plugin loading for built-in agents)
|
||||
Expect(mockLoader.pluginCallCount).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should NOT call LoadMediaAgent for invalid agent names", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Configure with an invalid agent name (not built-in, not a plugin)
|
||||
conf.Server.Agents = "invalid_agent"
|
||||
|
||||
// This should only result in using the local agent (as the invalid one is ignored)
|
||||
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||
|
||||
// Should get ErrNotFound since only local agent is available and it returns not found for this operation
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
|
||||
// Verify LoadMediaAgent was NEVER called for the invalid agent
|
||||
Expect(mockLoader.pluginCallCount).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
17
core/agents/agents_suite_test.go
Normal file
17
core/agents/agents_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAgents(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Agents Test Suite")
|
||||
}
|
||||
400
core/agents/agents_test.go
Normal file
400
core/agents/agents_test.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Agents", func() {
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
var ds model.DataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||
})
|
||||
|
||||
Describe("Local", func() {
|
||||
var ag *Agents
|
||||
BeforeEach(func() {
|
||||
conf.Server.Agents = ""
|
||||
ag = createAgents(ds, nil)
|
||||
})
|
||||
|
||||
It("calls the placeholder GetArtistImages", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
|
||||
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Agents", func() {
|
||||
var ag *Agents
|
||||
var mock *mockAgent
|
||||
BeforeEach(func() {
|
||||
mock = &mockAgent{}
|
||||
Register("fake", func(model.DataStore) Interface { return mock })
|
||||
Register("disabled", func(model.DataStore) Interface { return nil })
|
||||
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
|
||||
conf.Server.Agents = "empty,fake,disabled"
|
||||
ag = createAgents(ds, nil)
|
||||
Expect(ag.AgentName()).To(Equal("agents"))
|
||||
})
|
||||
|
||||
It("does not register disabled agents", func() {
|
||||
var ags []string
|
||||
for _, enabledAgent := range ag.getEnabledAgentNames() {
|
||||
agent := ag.getAgent(enabledAgent)
|
||||
if agent != nil {
|
||||
ags = append(ags, agent.AgentName())
|
||||
}
|
||||
}
|
||||
// local agent is always appended to the end of the agents list
|
||||
Expect(ags).To(HaveExactElements("empty", "fake", "local"))
|
||||
Expect(ags).ToNot(ContainElement("disabled"))
|
||||
})
|
||||
|
||||
Describe("GetArtistMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mbid).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistMBID(ctx, "123", "test")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetArtistMBID(ctx, "123", "test")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistURL", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(url).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("returns empty if artist is Various Artists", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("returns not found if artist is Unknown Artist", func() {
|
||||
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(BeEmpty())
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
|
||||
URL: "imageUrl",
|
||||
Size: 100,
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError("not found"))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("with multiple image agents", func() {
|
||||
var first *testImageAgent
|
||||
var second *testImageAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
first = &testImageAgent{Name: "imgFail", Err: errors.New("fail")}
|
||||
second = &testImageAgent{Name: "imgOk", Images: []ExternalImage{{URL: "ok", Size: 1}}}
|
||||
Register("imgFail", func(model.DataStore) Interface { return first })
|
||||
Register("imgOk", func(model.DataStore) Interface { return second })
|
||||
})
|
||||
|
||||
It("falls back to the next agent on error", func() {
|
||||
conf.Server.Agents = "imgFail,imgOk"
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}}))
|
||||
Expect(first.Args).To(HaveExactElements("id", "artist", "mbid"))
|
||||
Expect(second.Args).To(HaveExactElements("id", "artist", "mbid"))
|
||||
})
|
||||
|
||||
It("falls back if the first agent returns no images", func() {
|
||||
first.Err = nil
|
||||
first.Images = []ExternalImage{}
|
||||
conf.Server.Agents = "imgFail,imgOk"
|
||||
ag = createAgents(ds, nil)
|
||||
|
||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}}))
|
||||
Expect(first.Args).To(HaveExactElements("id", "artist", "mbid"))
|
||||
Expect(second.Args).To(HaveExactElements("id", "artist", "mbid"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarArtists", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)).To(Equal([]Artist{{
|
||||
Name: "Joe Dohn",
|
||||
MBID: "mbid321",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistTopSongs", func() {
|
||||
It("returns on first match", func() {
|
||||
conf.Server.DevExternalArtistFetchMultiplier = 1
|
||||
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
conf.Server.DevExternalArtistFetchMultiplier = 1
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
It("fetches with multiplier", func() {
|
||||
conf.Server.DevExternalArtistFetchMultiplier = 2
|
||||
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 4))
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
}))
|
||||
Expect(mock.Args).To(HaveExactElements("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(HaveExactElements("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(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []interface{}
|
||||
Err error
|
||||
}
|
||||
|
||||
func (a *mockAgent) AgentName() string {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
|
||||
a.Args = []interface{}{id, name}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
return "mbid", nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
return "url", nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
return "bio", nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []ExternalImage{{
|
||||
URL: "imageUrl",
|
||||
Size: 100,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
a.Args = []interface{}{id, name, mbid, limit}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Artist{{
|
||||
Name: "Joe Dohn",
|
||||
MBID: "mbid321",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, artistName, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Song{{
|
||||
Name: "A Song",
|
||||
MBID: "mbid444",
|
||||
}}, 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",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type emptyAgent struct {
|
||||
Interface
|
||||
}
|
||||
|
||||
func (e *emptyAgent) AgentName() string {
|
||||
return "empty"
|
||||
}
|
||||
|
||||
type testImageAgent struct {
|
||||
Name string
|
||||
Images []ExternalImage
|
||||
Err error
|
||||
Args []interface{}
|
||||
}
|
||||
|
||||
func (t *testImageAgent) AgentName() string { return t.Name }
|
||||
|
||||
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
t.Args = []interface{}{id, name, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
218
core/agents/deezer/client.go
Normal file
218
core/agents/deezer/client.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
bytes "bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const apiBaseURL = "https://api.deezer.com"
|
||||
const authBaseURL = "https://auth.deezer.com"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("deezer: not found")
|
||||
)
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type client struct {
|
||||
httpDoer httpDoer
|
||||
language string
|
||||
jwt jwtToken
|
||||
}
|
||||
|
||||
func newClient(hc httpDoer, language string) *client {
|
||||
return &client{
|
||||
httpDoer: hc,
|
||||
language: language,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("q", name)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
var results SearchArtistResults
|
||||
err = c.makeRequest(req, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(results.Data) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return results.Data, nil
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response any) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.httpDoer.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return c.parseError(data)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, response)
|
||||
}
|
||||
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var deezerError Error
|
||||
err := json.Unmarshal(data, &deezerError)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
|
||||
}
|
||||
|
||||
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results RelatedArtists
|
||||
err = c.makeRequest(req, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results.Data, nil
|
||||
}
|
||||
|
||||
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
|
||||
params := url.Values{}
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
var results TopTracks
|
||||
err = c.makeRequest(req, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results.Data, nil
|
||||
}
|
||||
|
||||
const pipeAPIURL = "https://pipe.deezer.com/api"
|
||||
|
||||
var strictPolicy = bluemonday.StrictPolicy()
|
||||
|
||||
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
|
||||
jwt, err := c.getJWT(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
|
||||
}
|
||||
|
||||
query := map[string]any{
|
||||
"operationName": "ArtistBio",
|
||||
"variables": map[string]any{
|
||||
"artistId": strconv.Itoa(artistID),
|
||||
},
|
||||
"query": `query ArtistBio($artistId: String!) {
|
||||
artist(artistId: $artistId) {
|
||||
bio {
|
||||
full
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept-Language", c.language)
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
|
||||
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
|
||||
resp, err := c.httpDoer.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type graphQLResponse struct {
|
||||
Data struct {
|
||||
Artist struct {
|
||||
Bio struct {
|
||||
Full string `json:"full"`
|
||||
} `json:"bio"`
|
||||
} `json:"artist"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
}
|
||||
|
||||
var result graphQLResponse
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
var errs []error
|
||||
for m := range result.Errors {
|
||||
errs = append(errs, errors.New(result.Errors[m].Message))
|
||||
}
|
||||
err := errors.Join(errs...)
|
||||
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
|
||||
}
|
||||
|
||||
if result.Data.Artist.Bio.Full == "" {
|
||||
return "", errors.New("deezer: biography not found")
|
||||
}
|
||||
|
||||
return cleanBio(result.Data.Artist.Bio.Full), nil
|
||||
}
|
||||
|
||||
func cleanBio(bio string) string {
|
||||
bio = strings.ReplaceAll(bio, "</p>", "\n")
|
||||
return strictPolicy.Sanitize(bio)
|
||||
}
|
||||
101
core/agents/deezer/client_auth.go
Normal file
101
core/agents/deezer/client_auth.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type jwtToken struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (j *jwtToken) get() (string, bool) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
if time.Now().Before(j.expiresAt) {
|
||||
return j.token, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (j *jwtToken) set(token string, expiresIn time.Duration) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
j.token = token
|
||||
j.expiresAt = time.Now().Add(expiresIn)
|
||||
}
|
||||
|
||||
func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||
// Check if we have a valid cached token
|
||||
if token, valid := c.jwt.get(); valid {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Fetch a new anonymous token
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpDoer.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
|
||||
var result authResponse
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
|
||||
}
|
||||
|
||||
if result.JWT == "" {
|
||||
return "", errors.New("deezer: no JWT token in response")
|
||||
}
|
||||
|
||||
// Parse JWT to get actual expiration time
|
||||
token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
|
||||
}
|
||||
|
||||
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
||||
expiresAt := token.Expiration()
|
||||
if expiresAt.IsZero() {
|
||||
return "", errors.New("deezer: JWT token has no expiration time")
|
||||
}
|
||||
|
||||
ttl := time.Until(expiresAt) - 1*time.Minute
|
||||
if ttl <= 0 {
|
||||
return "", errors.New("deezer: JWT token already expired or expires too soon")
|
||||
}
|
||||
|
||||
c.jwt.set(result.JWT, ttl)
|
||||
log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
|
||||
|
||||
return result.JWT, nil
|
||||
}
|
||||
293
core/agents/deezer/client_auth_test.go
Normal file
293
core/agents/deezer/client_auth_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("JWT Authentication", func() {
|
||||
var httpClient *fakeHttpClient
|
||||
var client *client
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient(httpClient, "en")
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
Describe("getJWT", func() {
|
||||
Context("with a valid JWT response", func() {
|
||||
It("successfully fetches and caches a JWT token", func() {
|
||||
testJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
||||
})
|
||||
|
||||
token, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).To(Equal(testJWT))
|
||||
})
|
||||
|
||||
It("returns the cached token on subsequent calls", func() {
|
||||
testJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
||||
})
|
||||
|
||||
// First call should fetch from API
|
||||
token1, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token1).To(Equal(testJWT))
|
||||
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
|
||||
|
||||
// Second call should return cached token without hitting API
|
||||
httpClient.lastRequest = nil // Clear last request to verify no new request is made
|
||||
token2, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token2).To(Equal(testJWT))
|
||||
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
|
||||
})
|
||||
|
||||
It("parses the JWT expiration time correctly", func() {
|
||||
expectedExpiration := time.Now().Add(5 * time.Minute)
|
||||
testToken, err := jwt.NewBuilder().
|
||||
Expiration(expectedExpiration).
|
||||
Build()
|
||||
Expect(err).To(BeNil())
|
||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
|
||||
})
|
||||
|
||||
token, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
// Verify the token is cached until close to expiration
|
||||
// The cache should expire 1 minute before the JWT expires
|
||||
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
|
||||
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with JWT tokens that expire soon", func() {
|
||||
It("rejects tokens that expire in less than 1 minute", func() {
|
||||
// Create a token that expires in 30 seconds (less than 1-minute buffer)
|
||||
testJWT := createTestJWT(30 * time.Second)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
||||
})
|
||||
|
||||
_, err := client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
||||
})
|
||||
|
||||
It("rejects already expired tokens", func() {
|
||||
// Create a token that expired 1 minute ago
|
||||
testJWT := createTestJWT(-1 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
||||
})
|
||||
|
||||
_, err := client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
||||
})
|
||||
|
||||
It("accepts tokens that expire in more than 1 minute", func() {
|
||||
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
|
||||
testJWT := createTestJWT(2 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
|
||||
})
|
||||
|
||||
token, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with invalid responses", func() {
|
||||
It("handles HTTP error responses", func() {
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 500,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
||||
})
|
||||
|
||||
_, err := client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
|
||||
})
|
||||
|
||||
It("handles malformed JSON responses", func() {
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
|
||||
})
|
||||
|
||||
_, err := client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
|
||||
})
|
||||
|
||||
It("handles responses with empty JWT field", func() {
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
|
||||
})
|
||||
|
||||
_, err := client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
|
||||
})
|
||||
|
||||
It("handles invalid JWT tokens", func() {
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
|
||||
})
|
||||
|
||||
_, err := client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
|
||||
})
|
||||
|
||||
It("rejects JWT tokens without expiration", func() {
|
||||
// Create a JWT without expiration claim
|
||||
testToken, err := jwt.NewBuilder().
|
||||
Claim("custom", "value").
|
||||
Build()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// Verify token has no expiration
|
||||
Expect(testToken.Expiration().IsZero()).To(BeTrue())
|
||||
|
||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
|
||||
})
|
||||
|
||||
_, err = client.getJWT(ctx)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("token caching behavior", func() {
|
||||
It("fetches a new token when the cached token expires", func() {
|
||||
// First token expires in 5 minutes
|
||||
firstJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
|
||||
})
|
||||
|
||||
token1, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token1).To(Equal(firstJWT))
|
||||
|
||||
// Manually expire the cached token
|
||||
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
|
||||
|
||||
// Second token with different expiration (10 minutes)
|
||||
secondJWT := createTestJWT(10 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
|
||||
})
|
||||
|
||||
token2, err := client.getJWT(ctx)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token2).To(Equal(secondJWT))
|
||||
Expect(token2).ToNot(Equal(token1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("jwtToken cache", func() {
|
||||
var cache *jwtToken
|
||||
|
||||
BeforeEach(func() {
|
||||
cache = &jwtToken{}
|
||||
})
|
||||
|
||||
It("returns false for expired tokens", func() {
|
||||
cache.set("test-token", -1*time.Second) // Already expired
|
||||
token, valid := cache.get()
|
||||
Expect(valid).To(BeFalse())
|
||||
Expect(token).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns true for valid tokens", func() {
|
||||
cache.set("test-token", 4*time.Minute)
|
||||
token, valid := cache.get()
|
||||
Expect(valid).To(BeTrue())
|
||||
Expect(token).To(Equal("test-token"))
|
||||
})
|
||||
|
||||
It("is thread-safe for concurrent access", func() {
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
// Writer goroutine
|
||||
wg.Go(func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
|
||||
// Reader goroutine
|
||||
wg.Go(func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.get()
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for both goroutines to complete
|
||||
wg.Wait()
|
||||
|
||||
// Verify final state is valid
|
||||
token, valid := cache.get()
|
||||
Expect(valid).To(BeTrue())
|
||||
Expect(token).To(HavePrefix("token-"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createTestJWT creates a valid JWT token for testing purposes
|
||||
func createTestJWT(expiresIn time.Duration) string {
|
||||
token, err := jwt.NewBuilder().
|
||||
Expiration(time.Now().Add(expiresIn)).
|
||||
Build()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create test JWT: %v", err))
|
||||
}
|
||||
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
|
||||
}
|
||||
return string(signed)
|
||||
}
|
||||
195
core/agents/deezer/client_test.go
Normal file
195
core/agents/deezer/client_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
var httpClient *fakeHttpClient
|
||||
var client *client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient(httpClient, "en")
|
||||
})
|
||||
|
||||
Describe("ArtistImages", func() {
|
||||
It("returns artist images from a successful request", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.search.artist.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artists).To(HaveLen(17))
|
||||
Expect(artists[0].Name).To(Equal("Michael Jackson"))
|
||||
Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
|
||||
})
|
||||
|
||||
It("fails if artist was not found", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ArtistBio", func() {
|
||||
BeforeEach(func() {
|
||||
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
|
||||
testJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
||||
})
|
||||
})
|
||||
|
||||
It("returns artist bio from a successful request", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
||||
Expect(bio).ToNot(ContainSubstring("<p>"))
|
||||
Expect(bio).ToNot(ContainSubstring("</p>"))
|
||||
})
|
||||
|
||||
It("uses the configured language", func() {
|
||||
client = newClient(httpClient, "fr")
|
||||
// Mock JWT token for the new client instance with a valid JWT
|
||||
testJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
||||
})
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
_, err = client.getArtistBio(GinkgoT().Context(), 27)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
|
||||
})
|
||||
|
||||
It("includes the JWT token in the request", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
_, err = client.getArtistBio(GinkgoT().Context(), 27)
|
||||
Expect(err).To(BeNil())
|
||||
// Verify that the Authorization header has the Bearer token format
|
||||
authHeader := httpClient.lastRequest.Header.Get("Authorization")
|
||||
Expect(authHeader).To(HavePrefix("Bearer "))
|
||||
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
|
||||
})
|
||||
|
||||
It("handles GraphQL errors", func() {
|
||||
errorResponse := `{
|
||||
"data": {
|
||||
"artist": {
|
||||
"bio": {
|
||||
"full": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{
|
||||
"message": "Artist not found"
|
||||
},
|
||||
{
|
||||
"message": "Invalid artist ID"
|
||||
}
|
||||
]
|
||||
}`
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 999)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
|
||||
Expect(err.Error()).To(ContainSubstring("Artist not found"))
|
||||
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
|
||||
})
|
||||
|
||||
It("handles empty biography", func() {
|
||||
emptyBioResponse := `{
|
||||
"data": {
|
||||
"artist": {
|
||||
"bio": {
|
||||
"full": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
Expect(err).To(MatchError("deezer: biography not found"))
|
||||
})
|
||||
|
||||
It("handles JWT token fetch failure", func() {
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 500,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
|
||||
})
|
||||
|
||||
It("handles JWT token that expires too soon", func() {
|
||||
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
|
||||
expiredJWT := createTestJWT(30 * time.Second)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeHttpClient struct {
|
||||
responses map[string]*http.Response
|
||||
lastRequest *http.Request
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) mock(url string, response http.Response) {
|
||||
if c.responses == nil {
|
||||
c.responses = make(map[string]*http.Response)
|
||||
}
|
||||
c.responses[url] = &response
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.lastRequest = req
|
||||
u := req.URL
|
||||
u.RawQuery = ""
|
||||
if resp, ok := c.responses[u.String()]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
panic("URL not mocked: " + u.String())
|
||||
}
|
||||
148
core/agents/deezer/deezer.go
Normal file
148
core/agents/deezer/deezer.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const deezerAgentName = "deezer"
|
||||
const deezerApiPictureXlSize = 1000
|
||||
const deezerApiPictureBigSize = 500
|
||||
const deezerApiPictureMediumSize = 250
|
||||
const deezerApiPictureSmallSize = 56
|
||||
const deezerArtistSearchLimit = 50
|
||||
|
||||
type deezerAgent struct {
|
||||
dataStore model.DataStore
|
||||
client *client
|
||||
}
|
||||
|
||||
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||
agent := &deezerAgent{dataStore: dataStore}
|
||||
httpClient := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
|
||||
return agent
|
||||
}
|
||||
|
||||
func (s *deezerAgent) AgentName() string {
|
||||
return deezerAgentName
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
|
||||
artist, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
log.Warn(ctx, "Artist not found in deezer", "artist", name)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling deezer", "artist", name, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []agents.ExternalImage
|
||||
possibleImages := []struct {
|
||||
URL string
|
||||
Size int
|
||||
}{
|
||||
{artist.PictureXl, deezerApiPictureXlSize},
|
||||
{artist.PictureBig, deezerApiPictureBigSize},
|
||||
{artist.PictureMedium, deezerApiPictureMediumSize},
|
||||
{artist.PictureSmall, deezerApiPictureSmallSize},
|
||||
}
|
||||
for _, imgData := range possibleImages {
|
||||
if imgData.URL != "" {
|
||||
res = append(res, agents.ExternalImage{
|
||||
URL: imgData.URL,
|
||||
Size: imgData.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
||||
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if !strings.EqualFold(artists[0].Name, name) {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
||||
artist, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
related, err := s.client.getRelatedArtists(ctx, artist.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := slice.Map(related, func(r Artist) agents.Artist {
|
||||
return agents.Artist{
|
||||
Name: r.Name,
|
||||
}
|
||||
})
|
||||
if len(res) > limit {
|
||||
res = res[:limit]
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
|
||||
artist, err := s.searchArtist(ctx, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := slice.Map(tracks, func(r Track) agents.Song {
|
||||
return agents.Song{
|
||||
Name: r.Title,
|
||||
}
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
|
||||
artist, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.client.getArtistBio(ctx, artist.ID)
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.Deezer.Enabled {
|
||||
agents.Register(deezerAgentName, deezerConstructor)
|
||||
}
|
||||
})
|
||||
}
|
||||
17
core/agents/deezer/deezer_suite_test.go
Normal file
17
core/agents/deezer/deezer_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestDeezer(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Deezer Test Suite")
|
||||
}
|
||||
66
core/agents/deezer/responses.go
Normal file
66
core/agents/deezer/responses.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package deezer
|
||||
|
||||
type SearchArtistResults struct {
|
||||
Data []Artist `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Picture string `json:"picture"`
|
||||
PictureSmall string `json:"picture_small"`
|
||||
PictureMedium string `json:"picture_medium"`
|
||||
PictureBig string `json:"picture_big"`
|
||||
PictureXl string `json:"picture_xl"`
|
||||
NbAlbum int `json:"nb_album"`
|
||||
NbFan int `json:"nb_fan"`
|
||||
Radio bool `json:"radio"`
|
||||
Tracklist string `json:"tracklist"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type RelatedArtists struct {
|
||||
Data []Artist `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type TopTracks struct {
|
||||
Data []Track `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Duration int `json:"duration"`
|
||||
Rank int `json:"rank"`
|
||||
Preview string `json:"preview"`
|
||||
Artist Artist `json:"artist"`
|
||||
Album Album `json:"album"`
|
||||
Contributors []Artist `json:"contributors"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
CoverSmall string `json:"cover_small"`
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXl string `json:"cover_xl"`
|
||||
Tracklist string `json:"tracklist"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
69
core/agents/deezer/responses_test.go
Normal file
69
core/agents/deezer/responses_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Responses", func() {
|
||||
Describe("Search type=artist", func() {
|
||||
It("parses the artist search result correctly ", func() {
|
||||
var resp SearchArtistResults
|
||||
body, err := os.ReadFile("tests/fixtures/deezer.search.artist.json")
|
||||
Expect(err).To(BeNil())
|
||||
err = json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Data).To(HaveLen(17))
|
||||
michael := resp.Data[0]
|
||||
Expect(michael.Name).To(Equal("Michael Jackson"))
|
||||
Expect(michael.PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var errorResp Error
|
||||
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
|
||||
err := json.Unmarshal(body, &errorResp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(errorResp.Error.Code).To(Equal(501))
|
||||
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Related Artists", func() {
|
||||
It("parses the related artists response correctly", func() {
|
||||
var resp RelatedArtists
|
||||
body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
|
||||
Expect(err).To(BeNil())
|
||||
err = json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Data).To(HaveLen(20))
|
||||
justice := resp.Data[0]
|
||||
Expect(justice.Name).To(Equal("Justice"))
|
||||
Expect(justice.ID).To(Equal(6404))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Top Tracks", func() {
|
||||
It("parses the top tracks response correctly", func() {
|
||||
var resp TopTracks
|
||||
body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
|
||||
Expect(err).To(BeNil())
|
||||
err = json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Data).To(HaveLen(5))
|
||||
track := resp.Data[0]
|
||||
Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
|
||||
Expect(track.ID).To(Equal(67238732))
|
||||
Expect(track.Album.Title).To(Equal("Random Access Memories"))
|
||||
})
|
||||
})
|
||||
})
|
||||
84
core/agents/interfaces.go
Normal file
84
core/agents/interfaces.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Constructor func(ds model.DataStore) Interface
|
||||
|
||||
type Interface interface {
|
||||
AgentName() string
|
||||
}
|
||||
|
||||
// AlbumInfo contains album metadata (no images)
|
||||
type AlbumInfo struct {
|
||||
Name string
|
||||
MBID string
|
||||
Description string
|
||||
URL string
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Name string
|
||||
MBID string
|
||||
}
|
||||
|
||||
type ExternalImage struct {
|
||||
URL string
|
||||
Size int
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
Name string
|
||||
MBID string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
// AlbumInfoRetriever provides album info (no images)
|
||||
type AlbumInfoRetriever interface {
|
||||
GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error)
|
||||
}
|
||||
|
||||
// AlbumImageRetriever provides album images
|
||||
type AlbumImageRetriever interface {
|
||||
GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error)
|
||||
}
|
||||
|
||||
type ArtistMBIDRetriever interface {
|
||||
GetArtistMBID(ctx context.Context, id string, name string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistURLRetriever interface {
|
||||
GetArtistURL(ctx context.Context, id, name, mbid string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistBiographyRetriever interface {
|
||||
GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error)
|
||||
}
|
||||
|
||||
type ArtistSimilarRetriever interface {
|
||||
GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error)
|
||||
}
|
||||
|
||||
type ArtistImageRetriever interface {
|
||||
GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error)
|
||||
}
|
||||
|
||||
type ArtistTopSongsRetriever interface {
|
||||
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
var Map map[string]Constructor
|
||||
|
||||
func Register(name string, init Constructor) {
|
||||
if Map == nil {
|
||||
Map = make(map[string]Constructor)
|
||||
}
|
||||
Map[name] = init
|
||||
}
|
||||
383
core/agents/lastfm/agent.go
Normal file
383
core/agents/lastfm/agent.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/andybalholm/cascadia"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
lastFMAgentName = "lastfm"
|
||||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
var ignoredBiographies = []string{
|
||||
// Unknown Artist
|
||||
`<a href="https://www.last.fm/music/`,
|
||||
}
|
||||
|
||||
type lastfmAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
client *client
|
||||
httpClient httpDoer
|
||||
getInfoMutex sync.Mutex
|
||||
}
|
||||
|
||||
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" {
|
||||
return nil
|
||||
}
|
||||
l := &lastfmAgent{
|
||||
ds: ds,
|
||||
lang: conf.Server.LastFM.Language,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.httpClient = chc
|
||||
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) AgentName() string {
|
||||
return lastFMAgentName
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return &agents.AlbumInfo{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
Description: a.Description.Summary,
|
||||
URL: a.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Last.fm can return duplicate sizes.
|
||||
seenSizes := map[int]bool{}
|
||||
images := make([]agents.ExternalImage, 0)
|
||||
|
||||
// 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
|
||||
}
|
||||
if _, exists := seenSizes[numericSize]; !exists {
|
||||
images = append(images, agents.ExternalImage{
|
||||
Size: numericSize,
|
||||
URL: img.URL,
|
||||
})
|
||||
seenSizes[numericSize] = true
|
||||
}
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.MBID == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
return a.MBID, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if a.URL == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
return a.URL, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
|
||||
if a.Bio.Summary == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
for _, ign := range ignoredBiographies {
|
||||
if strings.HasPrefix(a.Bio.Summary, ign) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
resp, err := l.callArtistGetSimilar(ctx, name, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
var res []agents.Artist
|
||||
for _, a := range resp {
|
||||
res = append(res, agents.Artist{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
resp, err := l.callArtistGetTopTracks(ctx, artistName, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
var res []agents.Song
|
||||
for _, t := range resp {
|
||||
res = append(res, agents.Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var (
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||
)
|
||||
|
||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get artist info: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create artist image request: %w", err)
|
||||
}
|
||||
resp, err := l.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get artist url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
node, err := html.Parse(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse html: %w", err)
|
||||
}
|
||||
|
||||
var res []agents.ExternalImage
|
||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||
if n == nil {
|
||||
return res, nil
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key != "content" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(attr.Val, artistIgnoredImage) {
|
||||
log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res = []agents.ExternalImage{
|
||||
{URL: attr.Val},
|
||||
}
|
||||
}
|
||||
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.Debug(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) (*Artist, error) {
|
||||
l.getInfoMutex.Lock()
|
||||
defer l.getInfoMutex.Unlock()
|
||||
|
||||
a, err := l.client.artistGetInfo(ctx, name)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
s, err := l.client.artistGetSimilar(ctx, name, limit)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, err)
|
||||
return nil, err
|
||||
}
|
||||
return s.Artists, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName string, count int) ([]Track, error) {
|
||||
t, err := l.client.artistGetTopTracks(ctx, artistName, count)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, err)
|
||||
return nil, err
|
||||
}
|
||||
return t.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
||||
return track.Participants[role][0].Name
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return scrobbler.ErrNotAuthorized
|
||||
}
|
||||
|
||||
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
|
||||
artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
mbid: track.MbzRecordingID,
|
||||
duration: int(track.Duration),
|
||||
albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
}
|
||||
|
||||
if s.Duration <= 30 {
|
||||
log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration)
|
||||
return nil
|
||||
}
|
||||
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
|
||||
artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
mbid: s.MbzRecordingID,
|
||||
duration: int(s.Duration),
|
||||
albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
|
||||
timestamp: s.TimeStamp,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
if !isLastFMError {
|
||||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 {
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
487
core/agents/lastfm/agent_test.go
Normal file
487
core/agents/lastfm/agent_test.go
Normal file
@@ -0,0 +1,487 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const (
|
||||
lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
|
||||
lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
|
||||
)
|
||||
|
||||
var _ = Describe("lastfmAgent", func() {
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LastFM.Enabled = true
|
||||
conf.Server.LastFM.ApiKey = "123"
|
||||
conf.Server.LastFM.Secret = "secret"
|
||||
})
|
||||
Describe("lastFMConstructor", func() {
|
||||
When("Agent is properly configured", func() {
|
||||
It("uses configured api key and language", func() {
|
||||
conf.Server.LastFM.Language = "pt"
|
||||
agent := lastFMConstructor(ds)
|
||||
Expect(agent.apiKey).To(Equal("123"))
|
||||
Expect(agent.secret).To(Equal("secret"))
|
||||
Expect(agent.lang).To(Equal("pt"))
|
||||
})
|
||||
})
|
||||
When("Agent is disabled", func() {
|
||||
It("returns nil", func() {
|
||||
conf.Server.LastFM.Enabled = false
|
||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
||||
})
|
||||
})
|
||||
When("ApiKey is empty", func() {
|
||||
It("returns nil", func() {
|
||||
conf.Server.LastFM.ApiKey = ""
|
||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
||||
})
|
||||
})
|
||||
When("Secret is empty", func() {
|
||||
It("returns nil", func() {
|
||||
conf.Server.LastFM.Secret = ""
|
||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography", 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.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).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.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
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.GetArtistBiography(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarArtists", 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 similar artists", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
|
||||
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
|
||||
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
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.GetSimilarArtists(ctx, "123", "U2", "", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistTopSongs", 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 top songs", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
|
||||
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
|
||||
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
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.GetArtistTopSongs(ctx, "123", "U2", "", 2)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobbling", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "en", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
AlbumArtist: "Track AlbumArtist",
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzRecordingID: "mbz-123",
|
||||
Participants: map[model.Role]model.ParticipantList{
|
||||
model.RoleArtist: []model.Participant{
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
|
||||
},
|
||||
model.RoleAlbumArtist: []model.Participant{
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("calls Last.fm with correct params", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
||||
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
When("ScrobbleFirstArtistOnly is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||
})
|
||||
|
||||
It("uses only the first artist", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scrobble", func() {
|
||||
It("calls Last.fm with correct params", func() {
|
||||
ts := time.Now()
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||
Expect(sentParams.Get("album")).To(Equal(track.Album))
|
||||
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
|
||||
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
|
||||
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
|
||||
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
|
||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
||||
})
|
||||
|
||||
When("ScrobbleFirstArtistOnly is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||
})
|
||||
|
||||
It("uses only the first artist", func() {
|
||||
ts := time.Now()
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
It("skips songs with less than 31 seconds", func() {
|
||||
track.Duration = 29
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 11", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 16", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrUnrecoverable on other errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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",
|
||||
}))
|
||||
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",
|
||||
}))
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
var agent *lastfmAgent
|
||||
var apiClient *tests.FakeHttpClient
|
||||
var httpClient *tests.FakeHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
apiClient = &tests.FakeHttpClient{}
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", apiClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
agent.httpClient = httpClient
|
||||
})
|
||||
|
||||
It("returns the artist image from the page", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(HaveLen(1))
|
||||
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
||||
})
|
||||
|
||||
It("returns empty list if image is the ignored default image", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty list if page has no meta tags", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error if API call fails", func() {
|
||||
apiClient.Err = errors.New("api error")
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("get artist info"))
|
||||
})
|
||||
|
||||
It("returns error if scraper call fails", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
httpClient.Err = errors.New("scraper error")
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("get artist url"))
|
||||
})
|
||||
})
|
||||
})
|
||||
132
core/agents/lastfm/auth_router.go
Normal file
132
core/agents/lastfm/auth_router.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
//go:embed token_received.html
|
||||
var tokenReceivedPage []byte
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
client *client
|
||||
apiKey string
|
||||
secret string
|
||||
}
|
||||
|
||||
func NewRouter(ds model.DataStore) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(r.apiKey, r.secret, "en", hc)
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(s.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
|
||||
r.Get("/link", s.getLinkStatus)
|
||||
r.Delete("/link", s.unlink)
|
||||
})
|
||||
|
||||
r.Get("/link/callback", s.callback)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"apiKey": s.apiKey,
|
||||
}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
resp["error"] = err
|
||||
resp["status"] = false
|
||||
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
err := s.sessionKeys.Delete(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
token, err := p.String("token")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||
return
|
||||
}
|
||||
uid, err := p.String("uid")
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||
return
|
||||
}
|
||||
|
||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||
// automatically contain any user info
|
||||
ctx := request.WithUser(r.Context(), model.User{ID: uid})
|
||||
err = s.fetchSessionKey(ctx, uid, token)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, "response", time.Now(), bytes.NewReader(tokenReceivedPage))
|
||||
}
|
||||
|
||||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||
sessionKey, err := s.client.getSession(ctx, token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
err = s.sessionKeys.Put(ctx, uid, sessionKey)
|
||||
if err != nil {
|
||||
log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
233
core/agents/lastfm/client.go
Normal file
233
core/agents/lastfm/client.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
||||
)
|
||||
|
||||
type lastFMError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *lastFMError) Error() string {
|
||||
return fmt.Sprintf("last.fm error(%d): %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
|
||||
return &client{apiKey, secret, lang, hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
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) (*Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("artist", name)
|
||||
params.Add("lang", c.lang)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Artist, nil
|
||||
}
|
||||
|
||||
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getSimilar")
|
||||
params.Add("artist", name)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.SimilarArtists, nil
|
||||
}
|
||||
|
||||
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getTopTracks")
|
||||
params.Add("artist", name)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.TopTracks, nil
|
||||
}
|
||||
|
||||
func (c *client) GetToken(ctx context.Context) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getToken")
|
||||
c.sign(params)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Token, nil
|
||||
}
|
||||
|
||||
func (c *client) getSession(ctx context.Context, token string) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getSession")
|
||||
params.Add("token", token)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Session.Key, nil
|
||||
}
|
||||
|
||||
type ScrobbleInfo struct {
|
||||
artist string
|
||||
track string
|
||||
album string
|
||||
trackNumber int
|
||||
mbid string
|
||||
duration int
|
||||
albumArtist string
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func (c *client) updateNowPlaying(ctx context.Context, sessionKey string, info ScrobbleInfo) error {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.updateNowPlaying")
|
||||
params.Add("artist", info.artist)
|
||||
params.Add("track", info.track)
|
||||
params.Add("album", info.album)
|
||||
params.Add("trackNumber", strconv.Itoa(info.trackNumber))
|
||||
params.Add("mbid", info.mbid)
|
||||
params.Add("duration", strconv.Itoa(info.duration))
|
||||
params.Add("albumArtist", info.albumArtist)
|
||||
params.Add("sk", sessionKey)
|
||||
resp, err := c.makeRequest(ctx, http.MethodPost, params, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.NowPlaying.IgnoredMessage.Code != "0" {
|
||||
log.Warn(ctx, "LastFM: NowPlaying was ignored", "code", resp.NowPlaying.IgnoredMessage.Code,
|
||||
"text", resp.NowPlaying.IgnoredMessage.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) scrobble(ctx context.Context, sessionKey string, info ScrobbleInfo) error {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.scrobble")
|
||||
params.Add("timestamp", strconv.FormatInt(info.timestamp.Unix(), 10))
|
||||
params.Add("artist", info.artist)
|
||||
params.Add("track", info.track)
|
||||
params.Add("album", info.album)
|
||||
params.Add("trackNumber", strconv.Itoa(info.trackNumber))
|
||||
params.Add("mbid", info.mbid)
|
||||
params.Add("duration", strconv.Itoa(info.duration))
|
||||
params.Add("albumArtist", info.albumArtist)
|
||||
params.Add("sk", sessionKey)
|
||||
resp, err := c.makeRequest(ctx, http.MethodPost, params, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Scrobbles.Scrobble.IgnoredMessage.Code != "0" {
|
||||
log.Warn(ctx, "LastFM: scrobble was ignored", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info)
|
||||
}
|
||||
if resp.Scrobbles.Attr.Accepted != 1 {
|
||||
log.Warn(ctx, "LastFM: scrobble was not accepted", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
|
||||
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(ctx context.Context, method string, params url.Values, signed bool) (*Response, error) {
|
||||
params.Add("format", "json")
|
||||
params.Add("api_key", c.apiKey)
|
||||
|
||||
if signed {
|
||||
c.sign(params)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response Response
|
||||
jsonErr := decoder.Decode(&response)
|
||||
if resp.StatusCode != 200 && jsonErr != nil {
|
||||
return nil, fmt.Errorf("last.fm http status: (%d)", resp.StatusCode)
|
||||
}
|
||||
if jsonErr != nil {
|
||||
return nil, jsonErr
|
||||
}
|
||||
if response.Error != 0 {
|
||||
return &response, &lastFMError{Code: response.Error, Message: response.Message}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (c *client) sign(params url.Values) {
|
||||
// the parameters must be in order before hashing
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if slices.Contains([]string{"format", "callback"}, k) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
msg := strings.Builder{}
|
||||
for _, k := range keys {
|
||||
msg.WriteString(k)
|
||||
msg.WriteString(params[k][0])
|
||||
}
|
||||
msg.WriteString(c.secret)
|
||||
hash := md5.Sum([]byte(msg.String()))
|
||||
params.Add("api_sig", hex.EncodeToString(hash[:]))
|
||||
}
|
||||
173
core/agents/lastfm/client_test.go
Normal file
173
core/agents/lastfm/client_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var client *client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
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() {
|
||||
It("returns an artist for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
artist, err := client.artistGetInfo(context.Background(), "U2")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artist.Name).To(Equal("U2"))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||
})
|
||||
|
||||
It("fails if Last.fm returns an http status != 200", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
Expect(err).To(MatchError("last.fm http status: (500)"))
|
||||
})
|
||||
|
||||
It("fails if Last.fm returns an http status != 200", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
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() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
||||
})
|
||||
|
||||
It("fails if HttpClient.Do() returns error", func() {
|
||||
httpClient.Err = errors.New("generic error")
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
Expect(err).To(MatchError("generic error"))
|
||||
})
|
||||
|
||||
It("fails if returned body is not a valid JSON", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`<xml>NOT_VALID_JSON</xml>`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("artistGetSimilar", func() {
|
||||
It("returns an artist for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(similar.Artists)).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("artistGetTopTracks", func() {
|
||||
It("returns top tracks for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(top.Track)).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetToken", func() {
|
||||
It("returns a token when the request is successful", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
|
||||
queryParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
|
||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getSession", func() {
|
||||
It("returns a session key when the request is successful", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
|
||||
queryParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
|
||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
||||
Expect(queryParams.Get("token")).To(Equal("TOKEN"))
|
||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sign", func() {
|
||||
It("adds an api_sig param with the signature", func() {
|
||||
params := url.Values{}
|
||||
params.Add("d", "444")
|
||||
params.Add("callback", "https://myserver.com")
|
||||
params.Add("a", "111")
|
||||
params.Add("format", "json")
|
||||
params.Add("c", "333")
|
||||
params.Add("b", "222")
|
||||
client.sign(params)
|
||||
Expect(params).To(HaveKey("api_sig"))
|
||||
sig := params.Get("api_sig")
|
||||
expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
|
||||
Expect(sig).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
})
|
||||
17
core/agents/lastfm/lastfm_suite_test.go
Normal file
17
core/agents/lastfm/lastfm_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestLastFM(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "LastFM Test Suite")
|
||||
}
|
||||
119
core/agents/lastfm/responses.go
Normal file
119
core/agents/lastfm/responses.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package lastfm
|
||||
|
||||
type Response struct {
|
||||
Artist Artist `json:"artist"`
|
||||
SimilarArtists SimilarArtists `json:"similarartists"`
|
||||
TopTracks TopTracks `json:"toptracks"`
|
||||
Album Album `json:"album"`
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
Session Session `json:"session"`
|
||||
NowPlaying NowPlaying `json:"nowplaying"`
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid"`
|
||||
URL string `json:"url"`
|
||||
Image []ExternalImage `json:"image"`
|
||||
Bio Description `json:"bio"`
|
||||
}
|
||||
|
||||
type SimilarArtists struct {
|
||||
Artists []Artist `json:"artist"`
|
||||
Attr Attr `json:"@attr"`
|
||||
}
|
||||
|
||||
type Attr struct {
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
|
||||
type ExternalImage struct {
|
||||
URL string `json:"#text"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
type Description struct {
|
||||
Published string `json:"published"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid"`
|
||||
}
|
||||
|
||||
type TopTracks struct {
|
||||
Track []Track `json:"track"`
|
||||
Attr Attr `json:"@attr"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Subscriber int `json:"subscriber"`
|
||||
}
|
||||
|
||||
type NowPlaying struct {
|
||||
Artist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"artist"`
|
||||
IgnoredMessage struct {
|
||||
Code string `json:"code"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"ignoredMessage"`
|
||||
Album struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"album"`
|
||||
AlbumArtist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"albumArtist"`
|
||||
Track struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"track"`
|
||||
}
|
||||
|
||||
type Scrobbles struct {
|
||||
Attr struct {
|
||||
Accepted int `json:"accepted"`
|
||||
Ignored int `json:"ignored"`
|
||||
} `json:"@attr"`
|
||||
Scrobble struct {
|
||||
Artist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"artist"`
|
||||
IgnoredMessage struct {
|
||||
Code string `json:"code"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"ignoredMessage"`
|
||||
AlbumArtist struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"albumArtist"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Album struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"album"`
|
||||
Track struct {
|
||||
Corrected string `json:"corrected"`
|
||||
Text string `json:"#text"`
|
||||
} `json:"track"`
|
||||
} `json:"scrobble"`
|
||||
}
|
||||
65
core/agents/lastfm/responses_test.go
Normal file
65
core/agents/lastfm/responses_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package lastfm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LastFM responses", func() {
|
||||
Describe("Artist", func() {
|
||||
It("parses the response correctly", func() {
|
||||
var resp Response
|
||||
body, _ := os.ReadFile("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Artist.Name).To(Equal("U2"))
|
||||
Expect(resp.Artist.MBID).To(Equal("a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432"))
|
||||
Expect(resp.Artist.URL).To(Equal("https://www.last.fm/music/U2"))
|
||||
Expect(resp.Artist.Bio.Summary).To(ContainSubstring("U2 é uma das mais importantes bandas de rock de todos os tempos"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SimilarArtists", func() {
|
||||
It("parses the response correctly", func() {
|
||||
var resp Response
|
||||
body, _ := os.ReadFile("tests/fixtures/lastfm.artist.getsimilar.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.SimilarArtists.Artists).To(HaveLen(2))
|
||||
Expect(resp.SimilarArtists.Artists[0].Name).To(Equal("Passengers"))
|
||||
Expect(resp.SimilarArtists.Artists[1].Name).To(Equal("INXS"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TopTracks", func() {
|
||||
It("parses the response correctly", func() {
|
||||
var resp Response
|
||||
body, _ := os.ReadFile("tests/fixtures/lastfm.artist.gettoptracks.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.TopTracks.Track).To(HaveLen(2))
|
||||
Expect(resp.TopTracks.Track[0].Name).To(Equal("Beautiful Day"))
|
||||
Expect(resp.TopTracks.Track[0].MBID).To(Equal("f7f264d0-a89b-4682-9cd7-a4e7c37637af"))
|
||||
Expect(resp.TopTracks.Track[1].Name).To(Equal("With or Without You"))
|
||||
Expect(resp.TopTracks.Track[1].MBID).To(Equal("6b9a509f-6907-4a6e-9345-2f12da09ba4b"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var error Response
|
||||
body := []byte(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)
|
||||
err := json.Unmarshal(body, &error)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(error.Error).To(Equal(3))
|
||||
Expect(error.Message).To(Equal("Invalid Method - No method with that name in this package"))
|
||||
})
|
||||
})
|
||||
})
|
||||
16
core/agents/lastfm/token_received.html
Normal file
16
core/agents/lastfm/token_received.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Account Linking Success</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2 id="msg"></h2>
|
||||
<script>
|
||||
setTimeout("document.getElementById('msg').innerHTML = 'Success! Your account is linked to Last.fm. You can close this tab now.';",2000)
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.close();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
129
core/agents/listenbrainz/agent.go
Normal file
129
core/agents/listenbrainz/agent.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
listenBrainzAgentName = "listenbrainz"
|
||||
sessionKeyProperty = "ListenBrainzSessionKey"
|
||||
)
|
||||
|
||||
type listenBrainzAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
baseURL string
|
||||
client *client
|
||||
}
|
||||
|
||||
func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||
l := &listenBrainzAgent{
|
||||
ds: ds,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) AgentName() string {
|
||||
return listenBrainzAgentName
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
||||
artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
|
||||
return p.MbzArtistID
|
||||
})
|
||||
artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
|
||||
return p.Name
|
||||
})
|
||||
li := listenInfo{
|
||||
TrackMetadata: trackMetadata{
|
||||
ArtistName: track.Artist,
|
||||
TrackName: track.Title,
|
||||
ReleaseName: track.Album,
|
||||
AdditionalInfo: additionalInfo{
|
||||
SubmissionClient: consts.AppName,
|
||||
SubmissionClientVersion: consts.Version,
|
||||
TrackNumber: track.TrackNumber,
|
||||
ArtistNames: artistNames,
|
||||
ArtistMBIDs: artistMBIDs,
|
||||
RecordingMBID: track.MbzRecordingID,
|
||||
ReleaseMBID: track.MbzAlbumID,
|
||||
ReleaseGroupMBID: track.MbzReleaseGroupID,
|
||||
DurationMs: int(track.Duration * 1000),
|
||||
},
|
||||
},
|
||||
}
|
||||
return li
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
}
|
||||
|
||||
li := l.formatListen(track)
|
||||
err = l.client.updateNowPlaying(ctx, sk, li)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
||||
}
|
||||
|
||||
li := l.formatListen(&s.MediaFile)
|
||||
li.ListenedAt = int(s.TimeStamp.Unix())
|
||||
err = l.client.scrobble(ctx, sk, li)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var lbErr *listenBrainzError
|
||||
isListenBrainzError := errors.As(err, &lbErr)
|
||||
if !isListenBrainzError {
|
||||
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
if lbErr.Code == 500 || lbErr.Code == 503 {
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
}
|
||||
|
||||
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
return err == nil && sk != ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return listenBrainzConstructor(ds)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
165
core/agents/listenbrainz/agent_test.go
Normal file
165
core/agents/listenbrainz/agent_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gstruct"
|
||||
)
|
||||
|
||||
var _ = Describe("listenBrainzAgent", func() {
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
var agent *listenBrainzAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = newClient("http://localhost:8080", httpClient)
|
||||
track = &model.MediaFile{
|
||||
ID: "123",
|
||||
Title: "Track Title",
|
||||
Album: "Track Album",
|
||||
Artist: "Track Artist",
|
||||
TrackNumber: 1,
|
||||
MbzRecordingID: "mbz-123",
|
||||
MbzAlbumID: "mbz-456",
|
||||
MbzReleaseGroupID: "mbz-789",
|
||||
Duration: 142.2,
|
||||
Participants: map[model.Role]model.ParticipantList{
|
||||
model.RoleArtist: []model.Participant{
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("formatListen", func() {
|
||||
It("constructs the listenInfo properly", func() {
|
||||
lr := agent.formatListen(track)
|
||||
Expect(lr).To(MatchAllFields(Fields{
|
||||
"ListenedAt": Equal(0),
|
||||
"TrackMetadata": MatchAllFields(Fields{
|
||||
"ArtistName": Equal(track.Artist),
|
||||
"TrackName": Equal(track.Title),
|
||||
"ReleaseName": Equal(track.Album),
|
||||
"AdditionalInfo": MatchAllFields(Fields{
|
||||
"SubmissionClient": Equal(consts.AppName),
|
||||
"SubmissionClientVersion": Equal(consts.Version),
|
||||
"TrackNumber": Equal(track.TrackNumber),
|
||||
"RecordingMBID": Equal(track.MbzRecordingID),
|
||||
"ReleaseMBID": Equal(track.MbzAlbumID),
|
||||
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
|
||||
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
|
||||
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
|
||||
"DurationMs": Equal(142200),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("updates NowPlaying successfully", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
||||
|
||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
var sc scrobbler.Scrobble
|
||||
|
||||
BeforeEach(func() {
|
||||
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
|
||||
})
|
||||
|
||||
It("sends a Scrobble successfully", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("sets the Timestamp properly", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
|
||||
var lr listenBrainzRequestBody
|
||||
err = decoder.Decode(&lr)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
|
||||
})
|
||||
|
||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
||||
err := agent.Scrobble(ctx, "user-2", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 503", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
|
||||
StatusCode: 503,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 500", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrUnrecoverable on other errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", sc)
|
||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||
})
|
||||
})
|
||||
})
|
||||
121
core/agents/listenbrainz/auth_router.go
Normal file
121
core/agents/listenbrainz/auth_router.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
type sessionKeysRepo interface {
|
||||
Put(ctx context.Context, userId, sessionKey string) error
|
||||
Get(ctx context.Context, userId string) (string, error)
|
||||
Delete(ctx context.Context, userId string) error
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
sessionKeys sessionKeysRepo
|
||||
client *client
|
||||
}
|
||||
|
||||
func NewRouter(ds model.DataStore) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.Authenticator(s.ds))
|
||||
r.Use(server.JWTRefresher)
|
||||
|
||||
r.Get("/link", s.getLinkStatus)
|
||||
r.Put("/link", s.link)
|
||||
r.Delete("/link", s.unlink)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
resp["error"] = err
|
||||
resp["status"] = false
|
||||
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
|
||||
return
|
||||
}
|
||||
resp["status"] = key != ""
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Router) link(w http.ResponseWriter, r *http.Request) {
|
||||
type tokenPayload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
var payload tokenPayload
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Token == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "Token is required")
|
||||
return
|
||||
}
|
||||
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
resp, err := s.client.validateToken(r.Context(), payload.Token)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Could not validate ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !resp.Valid {
|
||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "Invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token)
|
||||
if err != nil {
|
||||
log.Error("Could not save ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
|
||||
}
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
err := s.sessionKeys.Delete(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
} else {
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
|
||||
}
|
||||
}
|
||||
130
core/agents/listenbrainz/auth_router_test.go
Normal file
130
core/agents/listenbrainz/auth_router_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
var sk *fakeSessionKeys
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var r Router
|
||||
var req *http.Request
|
||||
var resp *httptest.ResponseRecorder
|
||||
|
||||
BeforeEach(func() {
|
||||
sk = &fakeSessionKeys{KeyName: sessionKeyProperty}
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
cl := newClient("http://localhost/", httpClient)
|
||||
r = Router{
|
||||
sessionKeys: sk,
|
||||
client: cl,
|
||||
}
|
||||
resp = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
Describe("getLinkStatus", func() {
|
||||
It("returns false when there is no stored session key", func() {
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(false))
|
||||
})
|
||||
|
||||
It("returns true when there is a stored session key", func() {
|
||||
sk.KeyValue = "sk-1"
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("link", func() {
|
||||
It("returns bad request when no token is sent", func() {
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns bad request when the token is invalid", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token invalid.", "valid": false}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "invalid-tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns true and the username when the token is valid", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
|
||||
})
|
||||
|
||||
It("saves the session key when the token is valid", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
Expect(sk.KeyValue).To(Equal("tok-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("unlink", func() {
|
||||
It("removes the session key when unlinking", func() {
|
||||
sk.KeyValue = "tok-1"
|
||||
req = httptest.NewRequest("DELETE", "/listenbrainz/link", nil)
|
||||
r.unlink(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
Expect(sk.KeyValue).To(Equal(""))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeSessionKeys struct {
|
||||
KeyName string
|
||||
KeyValue string
|
||||
}
|
||||
|
||||
func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
sk.KeyValue = sessionKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.KeyValue, nil
|
||||
}
|
||||
|
||||
func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error {
|
||||
sk.KeyValue = ""
|
||||
return nil
|
||||
}
|
||||
179
core/agents/listenbrainz/client.go
Normal file
179
core/agents/listenbrainz/client.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type listenBrainzError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *listenBrainzError) Error() string {
|
||||
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(baseURL string, hc httpDoer) *client {
|
||||
return &client{baseURL, hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
baseURL string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
type listenBrainzResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
Status string `json:"status"`
|
||||
Valid bool `json:"valid"`
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
type listenBrainzRequest struct {
|
||||
ApiKey string
|
||||
Body listenBrainzRequestBody
|
||||
}
|
||||
|
||||
type listenBrainzRequestBody struct {
|
||||
ListenType listenType `json:"listen_type,omitempty"`
|
||||
Payload []listenInfo `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type listenType string
|
||||
|
||||
const (
|
||||
Single listenType = "single"
|
||||
PlayingNow listenType = "playing_now"
|
||||
)
|
||||
|
||||
type listenInfo struct {
|
||||
ListenedAt int `json:"listened_at,omitempty"`
|
||||
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
|
||||
}
|
||||
|
||||
type trackMetadata struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
|
||||
}
|
||||
|
||||
type additionalInfo struct {
|
||||
SubmissionClient string `json:"submission_client,omitempty"`
|
||||
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
|
||||
TrackNumber int `json:"tracknumber,omitempty"`
|
||||
ArtistNames []string `json:"artist_names,omitempty"`
|
||||
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
|
||||
RecordingMBID string `json:"recording_mbid,omitempty"`
|
||||
ReleaseMBID string `json:"release_mbid,omitempty"`
|
||||
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
|
||||
DurationMs int `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
Body: listenBrainzRequestBody{
|
||||
ListenType: PlayingNow,
|
||||
Payload: []listenInfo{li},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
Body: listenBrainzRequestBody{
|
||||
ListenType: Single,
|
||||
Payload: []listenInfo{li},
|
||||
},
|
||||
}
|
||||
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) path(endpoint string) (string, error) {
|
||||
u, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.Path = path.Join(u.Path, endpoint)
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
|
||||
b, _ := json.Marshal(r.Body)
|
||||
uri, err := c.path(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
|
||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
||||
|
||||
if r.ApiKey != "" {
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
|
||||
}
|
||||
|
||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response listenBrainzResponse
|
||||
jsonErr := decoder.Decode(&response)
|
||||
if resp.StatusCode != 200 && jsonErr != nil {
|
||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
||||
}
|
||||
if jsonErr != nil {
|
||||
return nil, jsonErr
|
||||
}
|
||||
if response.Code != 0 && response.Code != 200 {
|
||||
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
120
core/agents/listenbrainz/client_test.go
Normal file
120
core/agents/listenbrainz/client_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
var client *client
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client = newClient("BASE_URL/", httpClient)
|
||||
})
|
||||
|
||||
Describe("listenBrainzResponse", func() {
|
||||
It("parses a response properly", func() {
|
||||
var response listenBrainzResponse
|
||||
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.Code).To(Equal(200))
|
||||
Expect(response.Message).To(Equal("Message"))
|
||||
Expect(response.UserName).To(Equal("UserName"))
|
||||
Expect(response.Valid).To(BeTrue())
|
||||
Expect(response.Status).To(Equal("ok"))
|
||||
Expect(response.Error).To(Equal("Error"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validateToken", func() {
|
||||
BeforeEach(func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
})
|
||||
|
||||
It("formats the request properly", func() {
|
||||
_, err := client.validateToken(context.Background(), "LB-TOKEN")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
||||
})
|
||||
|
||||
It("parses and returns the response", func() {
|
||||
res, err := client.validateToken(context.Background(), "LB-TOKEN")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Valid).To(Equal(true))
|
||||
Expect(res.UserName).To(Equal("ListenBrainzUser"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with listenInfo", func() {
|
||||
var li listenInfo
|
||||
BeforeEach(func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
li = listenInfo{
|
||||
TrackMetadata: trackMetadata{
|
||||
ArtistName: "Track Artist",
|
||||
TrackName: "Track Title",
|
||||
ReleaseName: "Track Album",
|
||||
AdditionalInfo: additionalInfo{
|
||||
TrackNumber: 1,
|
||||
ArtistNames: []string{"Artist 1", "Artist 2"},
|
||||
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
|
||||
RecordingMBID: "mbz-123",
|
||||
ReleaseMBID: "mbz-456",
|
||||
DurationMs: 142200,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("updateNowPlaying", func() {
|
||||
It("formats the request properly", func() {
|
||||
Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
||||
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
|
||||
Expect(body).To(MatchJSON(f))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scrobble", func() {
|
||||
BeforeEach(func() {
|
||||
li.ListenedAt = 1635000000
|
||||
})
|
||||
|
||||
It("formats the request properly", func() {
|
||||
Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
||||
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
|
||||
Expect(body).To(MatchJSON(f))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
17
core/agents/listenbrainz/listenbrainz_suite_test.go
Normal file
17
core/agents/listenbrainz/listenbrainz_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package listenbrainz
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestListenBrainz(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "ListenBrainz Test Suite")
|
||||
}
|
||||
52
core/agents/local_agent.go
Normal file
52
core/agents/local_agent.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const LocalAgentName = "local"
|
||||
|
||||
type localAgent struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func localsConstructor(ds model.DataStore) Interface {
|
||||
return &localAgent{ds}
|
||||
}
|
||||
|
||||
func (p *localAgent) AgentName() string {
|
||||
return LocalAgentName
|
||||
}
|
||||
|
||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "playCount",
|
||||
Order: "desc",
|
||||
Max: count,
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"artist_id": id},
|
||||
squirrel.Or{
|
||||
squirrel.Eq{"starred": true},
|
||||
squirrel.Eq{"rating": 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []Song
|
||||
for _, s := range top {
|
||||
result = append(result, Song{
|
||||
Name: s.Title,
|
||||
MBID: s.MbzReleaseTrackID,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(LocalAgentName, localsConstructor)
|
||||
}
|
||||
25
core/agents/session_keys.go
Normal file
25
core/agents/session_keys.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// SessionKeys is a simple wrapper around the UserPropsRepository
|
||||
type SessionKeys struct {
|
||||
model.DataStore
|
||||
KeyName string
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
||||
}
|
||||
|
||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||
}
|
||||
37
core/agents/session_keys_test.go
Normal file
37
core/agents/session_keys_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SessionKeys", func() {
|
||||
ctx := context.Background()
|
||||
user := model.User{ID: "u-1"}
|
||||
ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}}
|
||||
sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"}
|
||||
|
||||
It("uses the assigned key name", func() {
|
||||
Expect(sk.KeyName).To(Equal("fakeSessionKey"))
|
||||
})
|
||||
It("stores a value in the DB", func() {
|
||||
Expect(sk.Put(ctx, user.ID, "test-stored-value")).To(BeNil())
|
||||
})
|
||||
It("fetches the stored value", func() {
|
||||
value, err := sk.Get(ctx, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(value).To(Equal("test-stored-value"))
|
||||
})
|
||||
It("deletes the stored value", func() {
|
||||
Expect(sk.Delete(ctx, user.ID)).To(BeNil())
|
||||
})
|
||||
It("handles a not found value", func() {
|
||||
_, err := sk.Get(ctx, "u-2")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
116
core/agents/spotify/client.go
Normal file
116
core/agents/spotify/client.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const apiBaseUrl = "https://api.spotify.com/v1/"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("spotify: not found")
|
||||
)
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(id, secret string, hc httpDoer) *client {
|
||||
return &client{id, secret, hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
id string
|
||||
secret string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||
token, err := c.authorize(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("type", "artist")
|
||||
params.Add("q", name)
|
||||
params.Add("offset", "0")
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
req.Header.Add("Authorization", "Bearer "+token)
|
||||
|
||||
var results SearchResults
|
||||
err = c.makeRequest(req, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(results.Artists.Items) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return results.Artists.Items, err
|
||||
}
|
||||
|
||||
func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
payload := url.Values{}
|
||||
payload.Add("grant_type", "client_credentials")
|
||||
|
||||
encodePayload := payload.Encode()
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
|
||||
auth := c.id + ":" + c.secret
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
|
||||
|
||||
response := map[string]interface{}{}
|
||||
err := c.makeRequest(req, &response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if v, ok := response["access_token"]; ok {
|
||||
return v.(string), nil
|
||||
}
|
||||
log.Error(ctx, "Invalid spotify response", "resp", response)
|
||||
return "", errors.New("invalid response")
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response interface{}) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return c.parseError(data)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, response)
|
||||
}
|
||||
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var e Error
|
||||
err := json.Unmarshal(data, &e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
|
||||
}
|
||||
131
core/agents/spotify/client_test.go
Normal file
131
core/agents/spotify/client_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("client", func() {
|
||||
var httpClient *fakeHttpClient
|
||||
var client *client
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
|
||||
})
|
||||
|
||||
Describe("ArtistImages", func() {
|
||||
It("returns artist images from a successful request", func() {
|
||||
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
artists, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artists).To(HaveLen(20))
|
||||
Expect(artists[0].Popularity).To(Equal(82))
|
||||
|
||||
images := artists[0].Images
|
||||
Expect(images).To(HaveLen(3))
|
||||
Expect(images[0].Width).To(Equal(640))
|
||||
Expect(images[1].Width).To(Equal(320))
|
||||
Expect(images[2].Width).To(Equal(160))
|
||||
})
|
||||
|
||||
It("fails if artist was not found", func() {
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||
"artists" : {
|
||||
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
|
||||
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
|
||||
}}`)),
|
||||
})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
})
|
||||
|
||||
It("fails if not able to authorize", func() {
|
||||
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("authorize", func() {
|
||||
It("returns an access_token on successful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||
})
|
||||
|
||||
token, err := client.authorize(context.TODO())
|
||||
Expect(err).To(BeNil())
|
||||
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
|
||||
auth := httpClient.lastRequest.Header.Get("Authorization")
|
||||
Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
|
||||
})
|
||||
|
||||
It("fails on unsuccessful authorization", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||
})
|
||||
|
||||
It("fails on invalid JSON response", func() {
|
||||
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||
})
|
||||
|
||||
_, err := client.authorize(context.TODO())
|
||||
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeHttpClient struct {
|
||||
responses map[string]*http.Response
|
||||
lastRequest *http.Request
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) mock(url string, response http.Response) {
|
||||
if c.responses == nil {
|
||||
c.responses = make(map[string]*http.Response)
|
||||
}
|
||||
c.responses[url] = &response
|
||||
}
|
||||
|
||||
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.lastRequest = req
|
||||
u := req.URL
|
||||
u.RawQuery = ""
|
||||
if resp, ok := c.responses[u.String()]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
panic("URL not mocked: " + u.String())
|
||||
}
|
||||
30
core/agents/spotify/responses.go
Normal file
30
core/agents/spotify/responses.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package spotify
|
||||
|
||||
type SearchResults struct {
|
||||
Artists ArtistsResult `json:"artists"`
|
||||
}
|
||||
|
||||
type ArtistsResult struct {
|
||||
HRef string `json:"href"`
|
||||
Items []Artist `json:"items"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
Genres []string `json:"genres"`
|
||||
HRef string `json:"href"`
|
||||
ID string `json:"id"`
|
||||
Popularity int `json:"popularity"`
|
||||
Images []Image `json:"images"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"error"`
|
||||
Message string `json:"error_description"`
|
||||
}
|
||||
48
core/agents/spotify/responses_test.go
Normal file
48
core/agents/spotify/responses_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Responses", func() {
|
||||
Describe("Search type=artist", func() {
|
||||
It("parses the artist search result correctly ", func() {
|
||||
var resp SearchResults
|
||||
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
|
||||
err := json.Unmarshal(body, &resp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(resp.Artists.Items).To(HaveLen(20))
|
||||
u2 := resp.Artists.Items[0]
|
||||
Expect(u2.Name).To(Equal("U2"))
|
||||
Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
|
||||
Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
|
||||
Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
|
||||
Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
|
||||
Expect(u2.Images[0].Width).To(Equal(640))
|
||||
Expect(u2.Images[0].Height).To(Equal(640))
|
||||
Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
|
||||
Expect(u2.Images[1].Width).To(Equal(320))
|
||||
Expect(u2.Images[1].Height).To(Equal(320))
|
||||
Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
|
||||
Expect(u2.Images[2].Width).To(Equal(160))
|
||||
Expect(u2.Images[2].Height).To(Equal(160))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var errorResp Error
|
||||
body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
|
||||
err := json.Unmarshal(body, &errorResp)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(errorResp.Code).To(Equal("invalid_client"))
|
||||
Expect(errorResp.Message).To(Equal("Invalid client"))
|
||||
})
|
||||
})
|
||||
})
|
||||
96
core/agents/spotify/spotify.go
Normal file
96
core/agents/spotify/spotify.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
const spotifyAgentName = "spotify"
|
||||
|
||||
type spotifyAgent struct {
|
||||
ds model.DataStore
|
||||
id string
|
||||
secret string
|
||||
client *client
|
||||
}
|
||||
|
||||
func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
|
||||
return nil
|
||||
}
|
||||
l := &spotifyAgent{
|
||||
ds: ds,
|
||||
id: conf.Server.Spotify.ID,
|
||||
secret: conf.Server.Spotify.Secret,
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.id, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) AgentName() string {
|
||||
return spotifyAgentName
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := s.searchArtist(ctx, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling Spotify", "artist", name, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []agents.ExternalImage
|
||||
for _, img := range a.Images {
|
||||
res = append(res, agents.ExternalImage{
|
||||
URL: img.URL,
|
||||
Size: img.Width,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, 40)
|
||||
if err != nil || len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
|
||||
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
|
||||
sort.Slice(artists, func(i, j int) bool {
|
||||
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
|
||||
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
|
||||
return ai < aj
|
||||
})
|
||||
|
||||
// If the first one has the same name, that's the one
|
||||
if strings.ToLower(artists[0].Name) != name {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &artists[0], err
|
||||
}
|
||||
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(spotifyAgentName, spotifyConstructor)
|
||||
})
|
||||
}
|
||||
17
core/agents/spotify/spotify_suite_test.go
Normal file
17
core/agents/spotify/spotify_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSpotify(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Spotify Test Suite")
|
||||
}
|
||||
Reference in New Issue
Block a user