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")
|
||||
}
|
||||
201
core/archiver.go
Normal file
201
core/archiver.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Archiver interface {
|
||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
ZipShare(ctx context.Context, id string, w io.Writer) error
|
||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
shares Share
|
||||
}
|
||||
|
||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
||||
}
|
||||
|
||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
|
||||
}
|
||||
|
||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
||||
return mf.AlbumID
|
||||
})
|
||||
for _, album := range albums {
|
||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||
isMultiDisc := len(discs) > 1
|
||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||
for _, mf := range album {
|
||||
file := a.albumFilename(mf, format, isMultiDisc)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
}
|
||||
}
|
||||
err = z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||
z := zip.NewWriter(out)
|
||||
comment := "Downloaded from Navidrome"
|
||||
if format != "raw" && format != "" {
|
||||
comment = fmt.Sprintf("%s, transcoded to %s %dbps", comment, format, bitrate)
|
||||
}
|
||||
_ = z.SetComment(comment)
|
||||
return z
|
||||
}
|
||||
|
||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
||||
_, file := filepath.Split(mf.Path)
|
||||
if format != "raw" {
|
||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||
}
|
||||
if isMultiDisc {
|
||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
|
||||
s, err := a.shares.Load(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.Downloadable {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||
return err
|
||||
}
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
plsName := sanitizeName(name)
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: plsName + ".m3u",
|
||||
Modified: mfs[0].UpdatedAt,
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating playlist zip entry", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error writing m3u in zip", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) string {
|
||||
ext := mf.Suffix
|
||||
if format != "" && format != "raw" {
|
||||
ext = format
|
||||
}
|
||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
|
||||
}
|
||||
|
||||
func sanitizeName(target string) string {
|
||||
return strings.ReplaceAll(target, "/", "_")
|
||||
}
|
||||
|
||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||
path := mf.AbsolutePath()
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: filename,
|
||||
Modified: mf.UpdatedAt,
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating zip entry", "file", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
if format != "raw" && format != "" {
|
||||
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(w, r)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error zipping file", "file", path, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
236
core/archiver_test.go
Normal file
236
core/archiver_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Archiver", func() {
|
||||
var (
|
||||
arch core.Archiver
|
||||
ms *mockMediaStreamer
|
||||
ds *mockDataStore
|
||||
sh *mockShare
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ms = &mockMediaStreamer{}
|
||||
sh = &mockShare{}
|
||||
ds = &mockDataStore{}
|
||||
arch = core.NewArchiver(ms, ds, sh)
|
||||
})
|
||||
|
||||
Context("ZipAlbum", func() {
|
||||
It("zips an album correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipArtist", func() {
|
||||
It("zips an artist's albums correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||
}
|
||||
|
||||
mfRepo := &mockMediaFileRepository{}
|
||||
mfRepo.On("GetAll", []model.QueryOptions{{
|
||||
Filters: squirrel.Eq{"album_artist_id": "1"},
|
||||
Sort: "album",
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipShare", func() {
|
||||
It("zips a share correctly", func() {
|
||||
mfs := model.MediaFiles{
|
||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||
{ID: "2", Path: "test_data/02 - track2.mp3", Suffix: "mp3", Artist: "Artist 2", Title: "track2"},
|
||||
}
|
||||
|
||||
share := &model.Share{
|
||||
ID: "1",
|
||||
Downloadable: true,
|
||||
Format: "mp3",
|
||||
MaxBitRate: 128,
|
||||
Tracks: mfs,
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
Context("ZipPlaylist", func() {
|
||||
It("zips a playlist correctly", func() {
|
||||
tracks := []model.PlaylistTrack{
|
||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
||||
{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
|
||||
}
|
||||
|
||||
pls := &model.Playlist{
|
||||
ID: "1",
|
||||
Name: "Test Playlist",
|
||||
Tracks: tracks,
|
||||
}
|
||||
|
||||
plRepo := &mockPlaylistRepository{}
|
||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(3))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))
|
||||
|
||||
// Verify M3U content
|
||||
m3uFile, err := zr.File[2].Open()
|
||||
Expect(err).To(BeNil())
|
||||
defer m3uFile.Close()
|
||||
|
||||
m3uContent, err := io.ReadAll(m3uFile)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
|
||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockDataStore struct {
|
||||
mock.Mock
|
||||
model.DataStore
|
||||
}
|
||||
|
||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(model.MediaFileRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).(model.PlaylistRepository)
|
||||
}
|
||||
|
||||
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
||||
return &mockLibraryRepository{}
|
||||
}
|
||||
|
||||
type mockLibraryRepository struct {
|
||||
mock.Mock
|
||||
model.LibraryRepository
|
||||
}
|
||||
|
||||
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
||||
return "/music", nil
|
||||
}
|
||||
|
||||
type mockMediaFileRepository struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
args := m.Called(options)
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
type mockPlaylistRepository struct {
|
||||
mock.Mock
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||
}
|
||||
|
||||
type mockMediaStreamer struct {
|
||||
mock.Mock
|
||||
core.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
|
||||
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
mock.Mock
|
||||
core.Share
|
||||
}
|
||||
|
||||
func (m *mockShare) Load(ctx context.Context, id string) (*model.Share, error) {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Get(0).(*model.Share), args.Error(1)
|
||||
}
|
||||
130
core/artwork/artwork.go
Normal file
130
core/artwork/artwork.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
_ "image/gif"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("artwork unavailable")
|
||||
|
||||
type Artwork interface {
|
||||
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
provider external.Provider
|
||||
}
|
||||
|
||||
type artworkReader interface {
|
||||
cache.Item
|
||||
LastUpdated() time.Time
|
||||
Reader(ctx context.Context) (io.ReadCloser, string, error)
|
||||
}
|
||||
|
||||
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artID, err := a.getArtworkId(ctx, id)
|
||||
if err == nil {
|
||||
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
|
||||
}
|
||||
if errors.Is(err, ErrUnavailable) {
|
||||
if artID.Kind == model.KindArtistArtwork {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
|
||||
} else {
|
||||
reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
}
|
||||
return reader, consts.ServerStart, nil
|
||||
}
|
||||
return reader, lastUpdate, err
|
||||
}
|
||||
|
||||
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
|
||||
artReader, err := a.getArtworkReader(ctx, artID, size, square)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
|
||||
r, err := a.cache.Get(ctx, artReader)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) {
|
||||
log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err)
|
||||
}
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return r, artReader.LastUpdated(), nil
|
||||
}
|
||||
|
||||
type coverArtGetter interface {
|
||||
CoverArtID() model.ArtworkID
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) {
|
||||
if id == "" {
|
||||
return model.ArtworkID{}, ErrUnavailable
|
||||
}
|
||||
artID, err := model.ParseArtworkID(id)
|
||||
if err == nil {
|
||||
return artID, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id)
|
||||
entity, err := model.GetEntityByID(ctx, a.ds, id)
|
||||
if err != nil {
|
||||
return model.ArtworkID{}, err
|
||||
}
|
||||
if e, ok := entity.(coverArtGetter); ok {
|
||||
artID = e.CoverArtID()
|
||||
}
|
||||
switch e := entity.(type) {
|
||||
case *model.Artist:
|
||||
log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name)
|
||||
case *model.Album:
|
||||
log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist)
|
||||
case *model.MediaFile:
|
||||
log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album)
|
||||
case *model.Playlist:
|
||||
log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name)
|
||||
}
|
||||
return artID, nil
|
||||
}
|
||||
|
||||
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
|
||||
var artReader artworkReader
|
||||
var err error
|
||||
if size > 0 || square {
|
||||
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
}
|
||||
return artReader, err
|
||||
}
|
||||
328
core/artwork/artwork_internal_test.go
Normal file
328
core/artwork/artwork_internal_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
|
||||
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
|
||||
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
|
||||
|
||||
cache := GetImageCache()
|
||||
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
|
||||
aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork)
|
||||
})
|
||||
|
||||
Describe("albumArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if album is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
|
||||
})
|
||||
It("returns ErrUnavailable if embed path is not available", func() {
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
DescribeTable("CoverArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.CoverArtPriority = priority
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
|
||||
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
|
||||
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfAnotherWithEmbed,
|
||||
})
|
||||
})
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(expected))
|
||||
},
|
||||
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
|
||||
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
|
||||
)
|
||||
})
|
||||
})
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
})
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
mfWithEmbed,
|
||||
mfWithoutEmbed,
|
||||
mfCorruptedCover,
|
||||
})
|
||||
})
|
||||
It("returns embed cover", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("tests/fixtures/test.mp3"))
|
||||
})
|
||||
It("returns embed cover if successfully extracted by ffmpeg", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
It("returns album cover if media file has no cover art", func() {
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal("al-444_0"))
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
})
|
||||
When("Square is false", func() {
|
||||
It("returns a PNG if original image is a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(15))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(15))
|
||||
})
|
||||
It("returns a JPEG if original image is not a PNG", func() {
|
||||
conf.Server.CoverArtPriority = "cover.jpg"
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(format).To(Equal("jpeg"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img.Bounds().Size().X).To(Equal(200))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(200))
|
||||
})
|
||||
})
|
||||
When("When square is true", func() {
|
||||
var alCover model.Album
|
||||
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
|
||||
conf.Server.CoverArtPriority = coverFileName
|
||||
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, format, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(format).To(Equal("png"))
|
||||
Expect(img.Bounds().Size().X).To(Equal(size))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(size))
|
||||
},
|
||||
Entry("portrait png image", "png", false, 200),
|
||||
Entry("landscape png image", "png", true, 200),
|
||||
Entry("portrait jpg image", "jpg", false, 200),
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
17
core/artwork/artwork_suite_test.go
Normal file
17
core/artwork/artwork_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestArtwork(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Artwork Suite")
|
||||
}
|
||||
57
core/artwork/artwork_test.go
Normal file
57
core/artwork/artwork_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package artwork_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw artwork.Artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
cache := artwork.GetImageCache()
|
||||
ffmpeg = tests.NewMockFFmpeg("content from ffmpeg")
|
||||
aw = artwork.NewArtwork(ds, cache, ffmpeg, nil)
|
||||
})
|
||||
|
||||
Context("GetOrPlaceholder", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns placeholder if album is not in the DB", func() {
|
||||
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
phBytes, err := io.ReadAll(ph)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(result).To(Equal(phBytes))
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Get", func() {
|
||||
Context("Empty ID", func() {
|
||||
It("returns an ErrUnavailable error", func() {
|
||||
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
|
||||
Expect(err).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
163
core/artwork/cache_warmer.go
Normal file
163
core/artwork/cache_warmer.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"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/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/pl"
|
||||
)
|
||||
|
||||
type CacheWarmer interface {
|
||||
PreCache(artID model.ArtworkID)
|
||||
}
|
||||
|
||||
// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background
|
||||
// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original
|
||||
// image size, as well as the size defined in the UICoverArtSize constant.
|
||||
func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
// If image cache is disabled, return a NOOP implementation
|
||||
if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
buffer: make(map[model.ArtworkID]struct{}),
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
// Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts
|
||||
ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true})
|
||||
go a.run(ctx)
|
||||
return a
|
||||
}
|
||||
|
||||
type cacheWarmer struct {
|
||||
artwork Artwork
|
||||
buffer map[model.ArtworkID]struct{}
|
||||
mutex sync.Mutex
|
||||
cache cache.FileCache
|
||||
wakeSignal chan struct{}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
a.sendWakeSignal()
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) sendWakeSignal() {
|
||||
// Don't block if the previous signal was not read yet
|
||||
select {
|
||||
case a.wakeSignal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) run(ctx context.Context) {
|
||||
for {
|
||||
a.waitSignal(ctx, 10*time.Second)
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
a.mutex.Lock()
|
||||
bufferLen := len(a.buffer)
|
||||
a.mutex.Unlock()
|
||||
if bufferLen > 0 {
|
||||
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
a.mutex.Lock()
|
||||
|
||||
// If there's nothing to send, keep waiting
|
||||
if len(a.buffer) == 0 {
|
||||
a.mutex.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
batch := slices.Collect(maps.Keys(a.buffer))
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
|
||||
a.processBatch(ctx, batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) {
|
||||
select {
|
||||
case <-time.After(timeout):
|
||||
case <-a.wakeSignal:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) {
|
||||
log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch))
|
||||
input := pl.FromSlice(ctx, batch)
|
||||
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
|
||||
for err := range errs {
|
||||
log.Debug(ctx, "Error warming cache", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caching id='%s': %w", id, err)
|
||||
}
|
||||
defer r.Close()
|
||||
_, err = io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NoopCacheWarmer() CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
type noopCacheWarmer struct{}
|
||||
|
||||
func (a *noopCacheWarmer) PreCache(model.ArtworkID) {}
|
||||
222
core/artwork/cache_warmer_test.go
Normal file
222
core/artwork/cache_warmer_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := 0; i < 5; i++ {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetReady(v bool) {
|
||||
f.ready.Store(v)
|
||||
}
|
||||
44
core/artwork/image_cache.go
Normal file
44
core/artwork/image_cache.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type cacheKey struct {
|
||||
artID model.ArtworkID
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func (k *cacheKey) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s-%s.%d",
|
||||
k.artID.Kind,
|
||||
k.artID.ID,
|
||||
k.lastUpdate.UnixMilli(),
|
||||
)
|
||||
}
|
||||
|
||||
type imageCache struct {
|
||||
cache.FileCache
|
||||
}
|
||||
|
||||
func GetImageCache() cache.FileCache {
|
||||
return singleton.GetInstance(func() *imageCache {
|
||||
return &imageCache{
|
||||
FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
r, _, err := arg.(artworkReader).Reader(ctx)
|
||||
return r, err
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
147
core/artwork/reader_album.go
Normal file
147
core/artwork/reader_album.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/maruel/natural"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type albumArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
album model.Album
|
||||
updatedAt *time.Time
|
||||
imgFiles []string
|
||||
rootFolder string
|
||||
}
|
||||
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
|
||||
al, err := artwork.ds.Album(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &albumArtworkReader{
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
album: *al,
|
||||
updatedAt: imagesUpdateAt,
|
||||
imgFiles: imgFiles,
|
||||
rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""),
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = *a.updatedAt
|
||||
} else {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Key() string {
|
||||
var hash [16]byte
|
||||
if conf.Server.EnableExternalServices {
|
||||
hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s.%x.%t",
|
||||
a.cacheKey.Key(),
|
||||
hash,
|
||||
conf.Server.EnableExternalServices,
|
||||
)
|
||||
}
|
||||
func (a *albumArtworkReader) LastUpdated() time.Time {
|
||||
return a.album.UpdatedAt
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
|
||||
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
|
||||
case len(a.imgFiles) > 0:
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) {
|
||||
var folderIDs []string
|
||||
for _, album := range albums {
|
||||
folderIDs = append(folderIDs, album.FolderIDs...)
|
||||
}
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var paths []string
|
||||
var imgFiles []string
|
||||
var updatedAt time.Time
|
||||
for _, f := range folders {
|
||||
path := f.AbsolutePath()
|
||||
paths = append(paths, path)
|
||||
if f.ImagesUpdatedAt.After(updatedAt) {
|
||||
updatedAt = f.ImagesUpdatedAt
|
||||
}
|
||||
for _, img := range f.ImageFiles {
|
||||
imgFiles = append(imgFiles, filepath.Join(path, img))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort image files to ensure consistent selection of cover art
|
||||
// This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
|
||||
// by comparing base filenames without extensions
|
||||
slices.SortFunc(imgFiles, compareImageFiles)
|
||||
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
// compareImageFiles compares two image file paths for sorting.
|
||||
// It extracts the base filename (without extension) and compares case-insensitively.
|
||||
// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1".
|
||||
// Note: This function is called O(n log n) times during sorting, but in practice albums
|
||||
// typically have only 1-20 image files, making the repeated string operations negligible.
|
||||
func compareImageFiles(a, b string) int {
|
||||
// Case-insensitive comparison
|
||||
a = strings.ToLower(a)
|
||||
b = strings.ToLower(b)
|
||||
|
||||
// Extract base filenames without extensions
|
||||
baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a))
|
||||
baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
|
||||
|
||||
// Compare base names first, then full paths if equal
|
||||
return cmp.Or(
|
||||
natural.Compare(baseA, baseB),
|
||||
natural.Compare(a, b),
|
||||
)
|
||||
}
|
||||
120
core/artwork/reader_album_test.go
Normal file
120
core/artwork/reader_album_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album Artwork Reader", func() {
|
||||
Describe("loadAlbumFoldersPaths", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *fakeDataStore
|
||||
repo *fakeFolderRepo
|
||||
album model.Album
|
||||
now time.Time
|
||||
expectedAt time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Now().Truncate(time.Second)
|
||||
expectedAt = now.Add(5 * time.Minute)
|
||||
|
||||
// Set up the test folders with image files
|
||||
repo = &fakeFolderRepo{}
|
||||
ds = &fakeDataStore{
|
||||
folderRepo: repo,
|
||||
}
|
||||
album = model.Album{
|
||||
ID: "album1",
|
||||
Name: "Album",
|
||||
FolderIDs: []string{"folder1", "folder2", "folder3"},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns sorted image files", func() {
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album/Disc1",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc10",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
|
||||
// Check that image files are sorted by base name (without extension)
|
||||
Expect(imgFiles).To(HaveLen(5))
|
||||
|
||||
// Files should be sorted by base filename without extension, then by full path
|
||||
// "back" < "cover", so back.jpg comes first
|
||||
// Then all cover.jpg files, sorted by path
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
|
||||
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
|
||||
Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg")))
|
||||
})
|
||||
|
||||
It("prioritizes files without numeric suffixes", func() {
|
||||
// Test case for issue #4683: cover.jpg should come before cover.1.jpg
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
|
||||
// cover.jpg should come first because "cover" < "cover.1" < "cover.2"
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg")))
|
||||
})
|
||||
|
||||
It("handles case-insensitive sorting", func() {
|
||||
// Test that Cover.jpg and cover.jpg are treated as equivalent
|
||||
repo.result = []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgFiles).To(HaveLen(3))
|
||||
|
||||
// Files should be sorted case-insensitively: BACK, cover, Folder
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
|
||||
})
|
||||
})
|
||||
})
|
||||
198
core/artwork/reader_artist.go
Normal file
198
core/artwork/reader_artist.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxArtistFolderTraversalDepth defines how many directory levels to search
|
||||
// when looking for artist images (artist folder + parent directories)
|
||||
maxArtistFolderTraversalDepth = 3
|
||||
)
|
||||
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only consider albums where the artist is the sole album artist.
|
||||
als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"album_artist_id": artID.ID},
|
||||
squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &artistReader{
|
||||
a: artwork,
|
||||
provider: provider,
|
||||
artist: *ar,
|
||||
artistFolder: artistFolder,
|
||||
imgFiles: imgFiles,
|
||||
}
|
||||
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
|
||||
// change _after_ retrieving from external sources, making the key invalid
|
||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||
|
||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *artistReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID))
|
||||
return fmt.Sprintf(
|
||||
"%s.%t.%x",
|
||||
a.cacheKey.Key(),
|
||||
conf.Server.EnableExternalServices,
|
||||
hash,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *artistReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
|
||||
}
|
||||
}
|
||||
return ff
|
||||
}
|
||||
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
current := artistFolder
|
||||
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
|
||||
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
|
||||
return reader, path, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
|
||||
fsys := os.DirFS(folder)
|
||||
matches, err := fs.Glob(fsys, pattern)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Filter to valid image files
|
||||
var imagePaths []string
|
||||
for _, m := range matches {
|
||||
if !model.IsImageFile(m) {
|
||||
continue
|
||||
}
|
||||
imagePaths = append(imagePaths, m)
|
||||
}
|
||||
|
||||
// Sort image files by prioritizing base filenames without numeric
|
||||
// suffixes (e.g., artist.jpg before artist.1.jpg)
|
||||
slices.SortFunc(imagePaths, compareImageFiles)
|
||||
|
||||
// Try to open files in sorted order
|
||||
for _, p := range imagePaths {
|
||||
filePath := filepath.Join(folder, p)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
continue
|
||||
}
|
||||
return f, filePath, nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
|
||||
}
|
||||
|
||||
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||
if len(albums) == 0 {
|
||||
return "", time.Time{}, nil
|
||||
}
|
||||
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
|
||||
|
||||
folderPath := str.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
|
||||
folderPath, _ = filepath.Split(folderPath)
|
||||
}
|
||||
folderPath = filepath.Dir(folderPath)
|
||||
|
||||
// Manipulate the path to get the folder ID
|
||||
// TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM
|
||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||
|
||||
log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||
|
||||
// Get the last update time for the folder
|
||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||
if err != nil || len(folders) == 0 {
|
||||
log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||
"libPath", libPath, "libID", libID, err)
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||
}
|
||||
446
core/artwork/reader_artist_test.go
Normal file
446
core/artwork/reader_artist_test.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
fds *fakeDataStore
|
||||
repo *fakeFolderRepo
|
||||
albums model.Albums
|
||||
paths []string
|
||||
now time.Time
|
||||
expectedUpdTime time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(stubCoreAbsolutePath())
|
||||
|
||||
now = time.Now().Truncate(time.Second)
|
||||
expectedUpdTime = now.Add(5 * time.Minute)
|
||||
repo = &fakeFolderRepo{
|
||||
result: []model.Folder{
|
||||
{
|
||||
ImagesUpdatedAt: expectedUpdTime,
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
fds = &fakeDataStore{
|
||||
folderRepo: repo,
|
||||
}
|
||||
albums = model.Albums{
|
||||
{LibraryID: 1, ID: "album1", Name: "Album 1"},
|
||||
}
|
||||
})
|
||||
|
||||
When("no albums provided", func() {
|
||||
It("returns empty and zero time", func() {
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
When("artist has only one album", func() {
|
||||
It("returns the parent folder", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal("/music/artist"))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("the artist have multiple albums", func() {
|
||||
It("returns the common prefix for the albums paths", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/library/artist/one"),
|
||||
filepath.FromSlash("/music/library/artist/two"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist")))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("the album paths contain same prefix", func() {
|
||||
It("returns the common prefix", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
}
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(folder).To(Equal("/music/artist"))
|
||||
Expect(upd).To(Equal(expectedUpdTime))
|
||||
})
|
||||
})
|
||||
|
||||
When("ds.Folder().GetAll returns an error", func() {
|
||||
It("returns an error", func() {
|
||||
paths = []string{
|
||||
filepath.FromSlash("/music/artist/album1"),
|
||||
filepath.FromSlash("/music/artist/album2"),
|
||||
}
|
||||
repo.err = errors.New("fake error")
|
||||
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
||||
Expect(err).To(MatchError(ContainSubstring("fake error")))
|
||||
// Folder and time are empty on error.
|
||||
Expect(folder).To(BeEmpty())
|
||||
Expect(upd).To(BeZero())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("fromArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
testFunc sourceFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
When("artist folder contains matching image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/artist/artist.jpg
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds and returns the image", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
// Verify we can read the content
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist folder is empty but parent contains image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
|
||||
parentDir := filepath.Join(tempDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
albumDir := filepath.Join(artistDir, "album")
|
||||
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist image in parent directory
|
||||
artistImagePath := filepath.Join(parentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in parent directory", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("parent image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("image is two levels up", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/
|
||||
grandparentDir := filepath.Join(tempDir, "grandparent")
|
||||
parentDir := filepath.Join(grandparentDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist image in grandparent directory
|
||||
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in grandparent directory", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("grandparent image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("images exist at multiple levels", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure with images at multiple levels
|
||||
grandparentDir := filepath.Join(tempDir, "grandparent")
|
||||
parentDir := filepath.Join(grandparentDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist images at all levels
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("prioritizes the closest (artist folder) image", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist level"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("pattern matches multiple files", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create multiple matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns the first valid image file in sorted order", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
|
||||
// Should return an image file,
|
||||
// Files are sorted: jpg comes before png alphabetically.
|
||||
// .abc comes first, but it's not an image.
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("prioritizing files without numeric suffixes", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case for issue #4683: artist.jpg should come before artist.1.jpg
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create multiple matches with and without numeric suffixes
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
// Verify it's the main file, not a numbered variant
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist main"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("handling case-insensitive sorting", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case to ensure case-insensitive natural sorting
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create files with mixed case names
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "*.*")
|
||||
})
|
||||
|
||||
It("sorts case-insensitively", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
|
||||
// Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching files exist anywhere", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create non-matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns an error", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(reader).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'"))
|
||||
Expect(err.Error()).To(ContainSubstring("parent directories"))
|
||||
})
|
||||
})
|
||||
|
||||
When("directory traversal reaches filesystem root", func() {
|
||||
BeforeEach(func() {
|
||||
// Start from a shallow directory to test root boundary
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("handles root boundary gracefully", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(reader).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
// Should not panic or cause infinite loop
|
||||
})
|
||||
})
|
||||
|
||||
When("file exists but cannot be opened", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create a file that cannot be opened (permission denied)
|
||||
restrictedFile := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("logs warning and continues searching", func() {
|
||||
// This test depends on the ability to restrict file permissions
|
||||
// For now, we'll just ensure it doesn't panic and returns appropriate error
|
||||
reader, _, err := testFunc()
|
||||
// The file should be readable in test environment, so this will succeed
|
||||
// In a real scenario with permission issues, it would continue searching
|
||||
if err == nil {
|
||||
Expect(reader).ToNot(BeNil())
|
||||
reader.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
When("single album artist scenario (original issue)", func() {
|
||||
BeforeEach(func() {
|
||||
// Simulate the exact folder structure from the issue:
|
||||
// /music/artist/album1/ (single album)
|
||||
// /music/artist/artist.jpg (artist image that should be found)
|
||||
artistDir := filepath.Join(tempDir, "music", "artist")
|
||||
albumDir := filepath.Join(artistDir, "album1")
|
||||
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
||||
|
||||
// Create artist.jpg in the artist folder (this was not being found before)
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
|
||||
|
||||
// The fromArtistFolder is called with the artist folder path
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds artist.jpg in artist folder for single album artist", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
Expect(path).To(ContainSubstring("artist"))
|
||||
|
||||
// Verify the content
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("single album artist image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
model.FolderRepository
|
||||
result []model.Folder
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
type fakeDataStore struct {
|
||||
model.DataStore
|
||||
folderRepo *fakeFolderRepo
|
||||
}
|
||||
|
||||
func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository {
|
||||
return fds.folderRepo
|
||||
}
|
||||
|
||||
func stubCoreAbsolutePath() func() {
|
||||
// Override core.AbsolutePath to return a fixed string during tests.
|
||||
original := core.AbsolutePath
|
||||
core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string {
|
||||
return filepath.FromSlash("/music")
|
||||
}
|
||||
return func() {
|
||||
core.AbsolutePath = original
|
||||
}
|
||||
}
|
||||
65
core/artwork/reader_mediafile.go
Normal file
65
core/artwork/reader_mediafile.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type mediafileArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
mediafile model.MediaFile
|
||||
album model.Album
|
||||
}
|
||||
|
||||
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
|
||||
mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
al, err := artwork.ds.Album(ctx).Get(mf.AlbumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &mediafileArtworkReader{
|
||||
a: artwork,
|
||||
mediafile: *mf,
|
||||
album: *al,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
if al.UpdatedAt.After(mf.UpdatedAt) {
|
||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||
} else {
|
||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *mediafileArtworkReader) Key() string {
|
||||
return fmt.Sprintf(
|
||||
"%s.%t",
|
||||
a.cacheKey.Key(),
|
||||
conf.Server.EnableMediaFileCoverArt,
|
||||
)
|
||||
}
|
||||
func (a *mediafileArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff []sourceFunc
|
||||
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
|
||||
path := a.mediafile.AbsolutePath()
|
||||
ff = []sourceFunc{
|
||||
fromTag(ctx, path),
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, path),
|
||||
}
|
||||
}
|
||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
147
core/artwork/reader_playlist.go
Normal file
147
core/artwork/reader_playlist.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type playlistArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
pl model.Playlist
|
||||
}
|
||||
|
||||
const tileSize = 600
|
||||
|
||||
func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) {
|
||||
pl, err := artwork.ds.Playlist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &playlistArtworkReader{
|
||||
a: artwork,
|
||||
pl: *pl,
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = pl.UpdatedAt
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
ff := []sourceFunc{
|
||||
a.fromGeneratedTiledCover(ctx),
|
||||
fromAlbumPlaceholder(),
|
||||
}
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
tiles, err := a.loadTiles(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
r, err := a.createTiledImage(ctx, tiles)
|
||||
return r, "", err
|
||||
}
|
||||
}
|
||||
|
||||
func toAlbumArtworkIDs(albumIDs []string) []model.ArtworkID {
|
||||
return slice.Map(albumIDs, func(id string) model.ArtworkID {
|
||||
al := model.Album{ID: id}
|
||||
return al.CoverArtID()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) {
|
||||
tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false)
|
||||
albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err)
|
||||
return nil, err
|
||||
}
|
||||
ids := toAlbumArtworkIDs(albumIds)
|
||||
|
||||
var tiles []image.Image
|
||||
for _, id := range ids {
|
||||
r, _, err := fromAlbum(ctx, a.a, id)()
|
||||
if err == nil {
|
||||
tile, err := a.createTile(ctx, r)
|
||||
if err == nil {
|
||||
tiles = append(tiles, tile)
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
if len(tiles) == 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
switch len(tiles) {
|
||||
case 0:
|
||||
return nil, errors.New("could not find any eligible cover")
|
||||
case 2:
|
||||
tiles = append(tiles, tiles[1], tiles[0])
|
||||
case 3:
|
||||
tiles = append(tiles, tiles[0])
|
||||
}
|
||||
return tiles, nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) {
|
||||
img, _, err := image.Decode(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
var rgba draw.Image
|
||||
var err error
|
||||
if len(tiles) == 4 {
|
||||
rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}})
|
||||
draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src)
|
||||
draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src)
|
||||
err = png.Encode(buf, rgba)
|
||||
} else {
|
||||
err = png.Encode(buf, tiles[0])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
|
||||
func rect(pos int) image.Rectangle {
|
||||
r := image.Rectangle{}
|
||||
switch pos {
|
||||
case 1:
|
||||
r.Min.X = tileSize / 2
|
||||
case 2:
|
||||
r.Min.Y = tileSize / 2
|
||||
case 3:
|
||||
r.Min.X = tileSize / 2
|
||||
r.Min.Y = tileSize / 2
|
||||
}
|
||||
r.Max.X = r.Min.X + tileSize/2
|
||||
r.Max.Y = r.Min.Y + tileSize/2
|
||||
return r
|
||||
}
|
||||
116
core/artwork/reader_resized.go
Normal file
116
core/artwork/reader_resized.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type resizedArtworkReader struct {
|
||||
artID model.ArtworkID
|
||||
cacheKey string
|
||||
lastUpdate time.Time
|
||||
size int
|
||||
square bool
|
||||
a *artwork
|
||||
}
|
||||
|
||||
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
|
||||
r := &resizedArtworkReader{a: a}
|
||||
r.artID = artID
|
||||
r.size = size
|
||||
r.square = square
|
||||
|
||||
// Get lastUpdated and cacheKey from original artwork
|
||||
original, err := a.getArtworkReader(ctx, artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.cacheKey = original.Key()
|
||||
r.lastUpdate = original.LastUpdated()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) Key() string {
|
||||
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
|
||||
if a.square {
|
||||
return baseKey + ".square"
|
||||
}
|
||||
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) LastUpdated() time.Time {
|
||||
return a.lastUpdate
|
||||
}
|
||||
|
||||
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
// Get artwork in original size, possibly from cache
|
||||
orig, _, err := a.a.Get(ctx, a.artID, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer orig.Close()
|
||||
|
||||
resized, origSize, err := resizeImage(orig, a.size, a.square)
|
||||
if resized == nil {
|
||||
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
} else {
|
||||
log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err)
|
||||
}
|
||||
if err != nil || resized == nil {
|
||||
// if we couldn't resize the image, return the original
|
||||
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
|
||||
return orig, "", err
|
||||
}
|
||||
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
|
||||
}
|
||||
|
||||
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
|
||||
original, format, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
|
||||
if originalSize <= size && !square {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
var resized image.Image
|
||||
if originalSize >= size {
|
||||
resized = imaging.Fit(original, size, size, imaging.Lanczos)
|
||||
} else {
|
||||
if bounds.Max.Y < bounds.Max.X {
|
||||
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
|
||||
} else {
|
||||
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
|
||||
}
|
||||
}
|
||||
if square {
|
||||
bg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
resized = imaging.OverlayCenter(bg, resized, 1)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if format == "png" || square {
|
||||
err = png.Encode(buf, resized)
|
||||
} else {
|
||||
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
|
||||
}
|
||||
return buf, originalSize, err
|
||||
}
|
||||
194
core/artwork/sources.go
Normal file
194
core/artwork/sources.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
)
|
||||
|
||||
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
|
||||
for _, f := range extractFuncs {
|
||||
if ctx.Err() != nil {
|
||||
return nil, "", ctx.Err()
|
||||
}
|
||||
start := time.Now()
|
||||
r, path, err := f()
|
||||
if r != nil {
|
||||
msg := fmt.Sprintf("Found %s artwork", artID.Kind)
|
||||
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
|
||||
return r, path, nil
|
||||
}
|
||||
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
|
||||
}
|
||||
|
||||
type sourceFunc func() (r io.ReadCloser, path string, err error)
|
||||
|
||||
func (f sourceFunc) String() string {
|
||||
name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
|
||||
name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.")
|
||||
if _, after, found := strings.Cut(name, ")."); found {
|
||||
name = after
|
||||
}
|
||||
name = strings.TrimSuffix(name, ".func1")
|
||||
return name
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
for _, file := range files {
|
||||
_, name := filepath.Split(file)
|
||||
match, err := filepath.Match(pattern, strings.ToLower(name))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file)
|
||||
continue
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", file, err)
|
||||
continue
|
||||
}
|
||||
return f, file, err
|
||||
}
|
||||
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
|
||||
}
|
||||
}
|
||||
|
||||
// These regexes are used to match the picture type in the file, in the order they are listed.
|
||||
var picTypeRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`),
|
||||
regexp.MustCompile(`(?i).*front.*`),
|
||||
regexp.MustCompile(`(?i).*cover.*`),
|
||||
}
|
||||
|
||||
func fromTag(ctx context.Context, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
types := m.PictureTypes()
|
||||
if len(types) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded image found in %s", path)
|
||||
}
|
||||
|
||||
var picture *tag.Picture
|
||||
for _, regex := range picTypeRegexes {
|
||||
for _, t := range types {
|
||||
if regex.MatchString(t) {
|
||||
log.Trace(ctx, "Found embedded image", "type", t, "path", path)
|
||||
picture = m.Pictures(t)
|
||||
break
|
||||
}
|
||||
}
|
||||
if picture != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if picture == nil {
|
||||
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
|
||||
picture = m.Picture()
|
||||
}
|
||||
if picture == nil {
|
||||
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
r, err := ffmpeg.ExtractImage(ctx, path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return r, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _, err := a.Get(ctx, id, 0, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return r, id.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumPlaceholder() sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
r, _ := resources.FS().Open(consts.PlaceholderAlbumArt)
|
||||
return r, consts.PlaceholderAlbumArt, nil
|
||||
}
|
||||
}
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := provider.ArtistImage(ctx, ar.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return fromURL(ctx, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := provider.AlbumImage(ctx, al.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return fromURL(ctx, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status)
|
||||
}
|
||||
return resp.Body, imageUrl.String(), nil
|
||||
}
|
||||
11
core/artwork/wire_providers.go
Normal file
11
core/artwork/wire_providers.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewArtwork,
|
||||
GetImageCache,
|
||||
NewCacheWarmer,
|
||||
)
|
||||
147
core/auth/auth.go
Normal file
147
core/auth/auth.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
TokenAuth *jwtauth.JWTAuth
|
||||
)
|
||||
|
||||
// Init creates a JWTAuth object from the secret stored in the DB.
|
||||
// If the secret is not found, it will create a new one and store it in the DB.
|
||||
func Init(ds model.DataStore) {
|
||||
once.Do(func() {
|
||||
ctx := context.TODO()
|
||||
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
|
||||
|
||||
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
|
||||
if err != nil || secret == "" {
|
||||
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
|
||||
secret = createNewSecret(ctx, ds)
|
||||
} else {
|
||||
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
|
||||
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
|
||||
secret = createNewSecret(ctx, ds)
|
||||
}
|
||||
}
|
||||
|
||||
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func createBaseClaims() map[string]any {
|
||||
tokenClaims := map[string]any{}
|
||||
tokenClaims[jwt.IssuerKey] = consts.JWTIssuer
|
||||
return tokenClaims
|
||||
}
|
||||
|
||||
func CreatePublicToken(claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
for k, v := range claims {
|
||||
tokenClaims[k] = v
|
||||
}
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
if !exp.IsZero() {
|
||||
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
|
||||
}
|
||||
for k, v := range claims {
|
||||
tokenClaims[k] = v
|
||||
}
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
claims := createBaseClaims()
|
||||
claims[jwt.SubjectKey] = u.UserName
|
||||
claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
|
||||
claims["uid"] = u.ID
|
||||
claims["adm"] = u.IsAdmin
|
||||
token, _, err := TokenAuth.Encode(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
func TouchToken(token jwt.Token) (string, error) {
|
||||
claims, err := token.AsMap(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
|
||||
_, newToken, err := TokenAuth.Encode(claims)
|
||||
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (map[string]interface{}, error) {
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token.AsMap(context.Background())
|
||||
}
|
||||
|
||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
u, err := ds.User(ctx).FindFirstAdmin()
|
||||
if err != nil {
|
||||
c, err := ds.User(ctx).CountAll()
|
||||
if c == 0 && err == nil {
|
||||
log.Debug(ctx, "No admin user yet!", err)
|
||||
} else {
|
||||
log.Error(ctx, "No admin user found!", err)
|
||||
}
|
||||
u = &model.User{}
|
||||
}
|
||||
|
||||
ctx = request.WithUsername(ctx, u.UserName)
|
||||
return request.WithUser(ctx, *u)
|
||||
}
|
||||
|
||||
func createNewSecret(ctx context.Context, ds model.DataStore) string {
|
||||
secret := id.NewRandom()
|
||||
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||
return secret
|
||||
}
|
||||
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
|
||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
func getEncKey() []byte {
|
||||
key := cmp.Or(
|
||||
conf.Server.PasswordEncryptionKey,
|
||||
consts.DefaultEncryptionKey,
|
||||
)
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return sum[:]
|
||||
}
|
||||
111
core/auth/auth_test.go
Normal file
111
core/auth/auth_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Auth Test Suite")
|
||||
}
|
||||
|
||||
const (
|
||||
testJWTSecret = "not so secret"
|
||||
oneDay = 24 * time.Hour
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
conf.Server.SessionTimeout = 2 * oneDay
|
||||
})
|
||||
|
||||
var _ = Describe("Auth", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ds := &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
auth.Init(ds)
|
||||
})
|
||||
|
||||
Describe("Validate", func() {
|
||||
It("returns error with an invalid JWT token", func() {
|
||||
_, err := auth.Validate("invalid.token")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["iat"] = time.Now().Unix()
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = auth.Validate(tokenStr)
|
||||
Expect(err).To(MatchError("token is expired"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateToken", func() {
|
||||
It("creates a valid token", func() {
|
||||
u := &model.User{
|
||||
ID: "123",
|
||||
UserName: "johndoe",
|
||||
IsAdmin: true,
|
||||
}
|
||||
tokenStr, err := auth.CreateToken(u)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
|
||||
Expect(claims["sub"]).To(Equal("johndoe"))
|
||||
Expect(claims["uid"]).To(Equal("123"))
|
||||
Expect(claims["adm"]).To(Equal(true))
|
||||
Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TouchToken", func() {
|
||||
It("updates the expiration time", func() {
|
||||
yesterday := time.Now().Add(-oneDay)
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = yesterday.Unix()
|
||||
token, _, err := auth.TokenAuth.Encode(claims)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
touched, err := auth.TouchToken(token)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
decodedClaims, err := auth.Validate(touched)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
exp := decodedClaims["exp"].(time.Time)
|
||||
Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
})
|
||||
})
|
||||
})
|
||||
27
core/common.go
Normal file
27
core/common.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
||||
// TagLib and ffmpeg access files
|
||||
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
||||
libPath, err := ds.Library(ctx).GetPath(libId)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(libPath, path)
|
||||
}
|
||||
55
core/common_test.go
Normal file
55
core/common_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
)
|
||||
|
||||
var _ = Describe("common.go", func() {
|
||||
Describe("userName", func() {
|
||||
It("returns the username from context", func() {
|
||||
ctx := request.WithUser(context.Background(), model.User{UserName: "testuser"})
|
||||
Expect(userName(ctx)).To(Equal("testuser"))
|
||||
})
|
||||
|
||||
It("returns 'UNKNOWN' if no user in context", func() {
|
||||
ctx := context.Background()
|
||||
Expect(userName(ctx)).To(Equal("UNKNOWN"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AbsolutePath", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
libId int
|
||||
path string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
libId = 1
|
||||
path = "music/file.mp3"
|
||||
mockLib := &tests.MockLibraryRepo{}
|
||||
mockLib.SetData(model.Libraries{{ID: libId, Path: "/library/root"}})
|
||||
ds.MockedLibrary = mockLib
|
||||
})
|
||||
|
||||
It("returns the absolute path when library exists", func() {
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, libId, path)
|
||||
Expect(abs).To(Equal("/library/root/music/file.mp3"))
|
||||
})
|
||||
|
||||
It("returns the original path if library not found", func() {
|
||||
ctx := context.Background()
|
||||
abs := AbsolutePath(ctx, ds, 999, path)
|
||||
Expect(abs).To(Equal(path))
|
||||
})
|
||||
})
|
||||
})
|
||||
17
core/core_suite_test.go
Normal file
17
core/core_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCore(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
284
core/external/extdata_helper_test.go
vendored
Normal file
284
core/external/extdata_helper_test.go
vendored
Normal file
@@ -0,0 +1,284 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Shared Mock Implementations ---
|
||||
|
||||
// mockArtistRepo mocks model.ArtistRepository
|
||||
type mockArtistRepo struct {
|
||||
mock.Mock
|
||||
model.ArtistRepository
|
||||
}
|
||||
|
||||
func newMockArtistRepo() *mockArtistRepo {
|
||||
return &mockArtistRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||
for _, a := range artists {
|
||||
artistCopy := a
|
||||
m.On("Get", artistCopy.ID).Return(&artistCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Artist), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Artists), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockArtistRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName is a helper to set up a GetAll expectation for finding by name.
|
||||
func (m *mockArtistRepo) FindByName(name string, artist model.Artist) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Once()
|
||||
}
|
||||
|
||||
// mockMediaFileRepo mocks model.MediaFileRepository
|
||||
type mockMediaFileRepo struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||
return &mockMediaFileRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||
for _, mf := range mediaFiles {
|
||||
mfCopy := mf
|
||||
m.On("Get", mfCopy.ID).Return(&mfCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.MediaFile), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByMBID is a helper to set up a GetAll expectation for finding by MBID.
|
||||
func (m *mockMediaFileRepo) FindByMBID(mbid string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// FindByArtistAndTitle is a helper to set up a GetAll expectation for finding by artist/title.
|
||||
func (m *mockMediaFileRepo) FindByArtistAndTitle(artistID string, title string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// mockAlbumRepo mocks model.AlbumRepository
|
||||
type mockAlbumRepo struct {
|
||||
mock.Mock
|
||||
model.AlbumRepository
|
||||
}
|
||||
|
||||
func newMockAlbumRepo() *mockAlbumRepo {
|
||||
return &mockAlbumRepo{}
|
||||
}
|
||||
|
||||
// Get implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Album), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Albums), args.Error(1)
|
||||
}
|
||||
|
||||
// mockSimilarArtistAgent mocks agents implementing ArtistTopSongsRetriever and ArtistSimilarRetriever
|
||||
type mockSimilarArtistAgent struct {
|
||||
mock.Mock
|
||||
agents.Interface // Embed to satisfy methods not explicitly mocked
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) AgentName() string {
|
||||
return "mockSimilar"
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
// mockAgents mocks the main Agents interface used by Provider
|
||||
type mockAgents struct {
|
||||
mock.Mock // Embed testify mock
|
||||
topSongsAgent agents.ArtistTopSongsRetriever
|
||||
similarAgent agents.ArtistSimilarRetriever
|
||||
imageAgent agents.ArtistImageRetriever
|
||||
albumInfoAgent interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
bioAgent agents.ArtistBiographyRetriever
|
||||
mbidAgent agents.ArtistMBIDRetriever
|
||||
urlAgent agents.ArtistURLRetriever
|
||||
agents.Interface
|
||||
}
|
||||
|
||||
func (m *mockAgents) AgentName() string {
|
||||
return "mockCombined"
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
if m.similarAgent != nil {
|
||||
return m.similarAgent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
if m.topSongsAgent != nil {
|
||||
return m.topSongsAgent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
|
||||
}
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
if m.albumInfoAgent != nil {
|
||||
return m.albumInfoAgent.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
}
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
if m.mbidAgent != nil {
|
||||
return m.mbidAgent.GetArtistMBID(ctx, id, name)
|
||||
}
|
||||
args := m.Called(ctx, id, name)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.urlAgent != nil {
|
||||
return m.urlAgent.GetArtistURL(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.bioAgent != nil {
|
||||
return m.bioAgent.GetArtistBiography(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
if m.imageAgent != nil {
|
||||
return m.imageAgent.GetArtistImages(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
if m.albumInfoAgent != nil {
|
||||
return m.albumInfoAgent.GetAlbumImages(ctx, name, artist, mbid)
|
||||
}
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
17
core/external/extdata_suite_test.go
vendored
Normal file
17
core/external/extdata_suite_test.go
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestExternal(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "External Suite")
|
||||
}
|
||||
733
core/external/provider.go
vendored
Normal file
733
core/external/provider.go
vendored
Normal file
@@ -0,0 +1,733 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/deezer"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSimilarArtists = 100
|
||||
refreshDelay = 5 * time.Second
|
||||
refreshTimeout = 15 * time.Second
|
||||
refreshQueueLength = 2000
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
||||
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
ds model.DataStore
|
||||
ag Agents
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
model.Album
|
||||
}
|
||||
|
||||
// Name returns the appropriate album name for external API calls
|
||||
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
||||
func (a *auxAlbum) Name() string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return a.Album.Name
|
||||
}
|
||||
return str.Clear(a.Album.Name)
|
||||
}
|
||||
|
||||
type auxArtist struct {
|
||||
model.Artist
|
||||
}
|
||||
|
||||
// Name returns the appropriate artist name for external API calls
|
||||
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
||||
func (a *auxArtist) Name() string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return a.Artist.Name
|
||||
}
|
||||
return str.Clear(a.Artist.Name)
|
||||
}
|
||||
|
||||
type Agents interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
agents.ArtistBiographyRetriever
|
||||
agents.ArtistMBIDRetriever
|
||||
agents.ArtistImageRetriever
|
||||
agents.ArtistSimilarRetriever
|
||||
agents.ArtistTopSongsRetriever
|
||||
agents.ArtistURLRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
e := &provider{ds: ds, ag: agents}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxAlbum{}, err
|
||||
}
|
||||
|
||||
var album auxAlbum
|
||||
switch v := entity.(type) {
|
||||
case *model.Album:
|
||||
album.Album = *v
|
||||
case *model.MediaFile:
|
||||
return e.getAlbum(ctx, v.AlbumID)
|
||||
default:
|
||||
return auxAlbum{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
log.Info(ctx, "Not found", "id", id)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
albumName := album.Name()
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
|
||||
album, err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateAlbumInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
|
||||
e.albumQueue.enqueue(&album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
start := time.Now()
|
||||
albumName := album.Name()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return album, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
|
||||
"elapsed", time.Since(start), err)
|
||||
return album, err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
album.Description = info.Description
|
||||
}
|
||||
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err == nil && len(images) > 0 {
|
||||
sort.Slice(images, func(i, j int) bool {
|
||||
return images[i].Size > images[j].Size
|
||||
})
|
||||
|
||||
album.LargeImageUrl = images[0].URL
|
||||
|
||||
if len(images) >= 2 {
|
||||
album.MediumImageUrl = images[1].URL
|
||||
}
|
||||
|
||||
if len(images) >= 3 {
|
||||
album.SmallImageUrl = images[2].URL
|
||||
}
|
||||
}
|
||||
|
||||
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
}
|
||||
|
||||
var artist auxArtist
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
artist.Artist = *v
|
||||
case *model.MediaFile:
|
||||
return e.getArtist(ctx, v.ArtistID)
|
||||
case *model.Album:
|
||||
return e.getArtist(ctx, v.AlbumArtistID)
|
||||
default:
|
||||
return auxArtist{}, model.ErrNotFound
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = e.loadSimilar(ctx, &artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
}
|
||||
|
||||
// If we don't have any info, retrieves it now
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
artistName := artist.Name()
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
|
||||
artist, err = e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
artistName := artist.Name()
|
||||
if artist.MbzArtistID == "" {
|
||||
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
|
||||
if mbid != "" && err == nil {
|
||||
artist.MbzArtistID = mbid
|
||||
}
|
||||
}
|
||||
|
||||
// Call all registered agents and collect information
|
||||
g := errgroup.Group{}
|
||||
g.SetLimit(2)
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
_ = g.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := max(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
weight := topCount * (4 + artistWeight)
|
||||
for _, mf := range topSongs {
|
||||
weightedSongs.Add(mf, weight)
|
||||
weight -= 4
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = addArtist(artist.Artist, weightedSongs, count, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
err := addArtist(a, weightedSongs, count, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var similarSongs model.MediaFiles
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.Pick()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s)
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(imageUrl)
|
||||
}
|
||||
|
||||
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
albumName := album.Name()
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumImages call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(images) == 0 {
|
||||
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
// Return the biggest image
|
||||
var img agents.ExternalImage
|
||||
for _, i := range images {
|
||||
if img.Size <= i.Size {
|
||||
img = i
|
||||
}
|
||||
}
|
||||
if img.URL == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(img.URL)
|
||||
}
|
||||
|
||||
func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.findArtistByName(ctx, artistName)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "TopSongs not found", "name", artistName)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "TopSongs call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
artistName := artist.Name()
|
||||
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
|
||||
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||
} else {
|
||||
log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
|
||||
}
|
||||
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
titleMap := map[string]string{}
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||
continue
|
||||
}
|
||||
sanitized := str.SanitizeFieldForSorting(s.Name)
|
||||
titleMap[sanitized] = s.Name
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(titleMap) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
titleFilters := squirrel.Or{}
|
||||
for sanitized := range titleMap {
|
||||
titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
|
||||
}
|
||||
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Or{
|
||||
squirrel.Eq{"artist_id": artist.ID},
|
||||
squirrel.Eq{"album_artist_id": artist.ID},
|
||||
},
|
||||
titleFilters,
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
sanitized := str.SanitizeFieldForSorting(mf.Title)
|
||||
if _, ok := matches[sanitized]; !ok {
|
||||
matches[sanitized] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
artist.ExternalUrl = artisURL
|
||||
}
|
||||
|
||||
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bio = str.SanitizeText(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
|
||||
if len(images) >= 1 {
|
||||
artist.LargeImageUrl = images[0].URL
|
||||
}
|
||||
if len(images) >= 2 {
|
||||
artist.MediumImageUrl = images[1].URL
|
||||
}
|
||||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
}
|
||||
|
||||
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
artistName := artist.Name()
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
|
||||
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
artist.SimilarArtists = sa
|
||||
}
|
||||
|
||||
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {
|
||||
var result model.Artists
|
||||
var notPresent []string
|
||||
|
||||
artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name })
|
||||
|
||||
// Query all artists at once
|
||||
clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer {
|
||||
return squirrel.Like{"artist.name": name}
|
||||
})
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Or(clauses),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
artistMap := make(map[string]model.Artist)
|
||||
for _, artist := range artists {
|
||||
artistMap[artist.Name] = artist
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
// Process the similar artists
|
||||
for _, s := range similar {
|
||||
if artist, found := artistMap[s.Name]; found {
|
||||
result = append(result, artist)
|
||||
count++
|
||||
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
notPresent = append(notPresent, s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Then fill up with non-present artists
|
||||
if includeNotPresent && count < limit {
|
||||
for _, s := range notPresent {
|
||||
// Let the ID empty to indicate that the artist is not present in the DB
|
||||
sa := model.Artist{Name: s}
|
||||
result = append(result, sa)
|
||||
|
||||
count++
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &auxArtist{Artist: artists[0]}, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
var ids []string
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if sa.ID == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, sa.ID)
|
||||
}
|
||||
|
||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Use a map and iterate through original array, to keep the same order
|
||||
artistMap := make(map[string]model.Artist)
|
||||
for _, sa := range similar {
|
||||
artistMap[sa.ID] = sa
|
||||
}
|
||||
|
||||
var loaded model.Artists
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if len(loaded) >= count {
|
||||
break
|
||||
}
|
||||
la, ok := artistMap[sa.ID]
|
||||
if !ok {
|
||||
if !includeNotPresent {
|
||||
continue
|
||||
}
|
||||
la = sa
|
||||
la.ID = ""
|
||||
}
|
||||
loaded = append(loaded, la)
|
||||
}
|
||||
artist.SimilarArtists = loaded
|
||||
return nil
|
||||
}
|
||||
|
||||
type refreshQueue[T any] chan<- *T
|
||||
|
||||
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) (T, error)) refreshQueue[T] {
|
||||
queue := make(chan *T, refreshQueueLength)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(refreshDelay):
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case item := <-queue:
|
||||
_, _ = processFn(ctx, *item)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return queue
|
||||
}
|
||||
|
||||
func (q *refreshQueue[T]) enqueue(item *T) {
|
||||
select {
|
||||
case *q <- item:
|
||||
default: // It is ok to miss a refresh request
|
||||
}
|
||||
}
|
||||
364
core/external/provider_albumimage_test.go
vendored
Normal file
364
core/external/provider_albumimage_test.go
vendored
Normal file
@@ -0,0 +1,364 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - AlbumImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockAlbumAgent *mockAlbumInfoAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockAlbum" // Configure mock agent
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
// Default mock for non-existent entities - Use Maybe() for flexibility
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the album is not found in the DB", func() {
|
||||
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "not-found")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
agentErr := errors.New("agent failure")
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("agent failure"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(ctx)
|
||||
// Mock the necessary DB calls *before* canceling the context
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Expect the agent call even if context is cancelled, returning the context error
|
||||
mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
|
||||
// Cancel the context *before* calling the function under test
|
||||
cancelCtx()
|
||||
|
||||
imgURL, err := provider.AlbumImage(cctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("context canceled"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
// Agent should now be called, verify this expectation
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("derives album ID from MediaFile ID", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if deriving album ID fails", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
|
||||
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
Context("Unicode handling in album names", func() {
|
||||
var albumWithEnDash *model.Album
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
|
||||
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in album name
|
||||
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/album.jpg")
|
||||
|
||||
// Mock the album agent to return an image for the album
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/album.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
|
||||
It("preserves Unicode characters in album names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockAlbumInfoAgent implementation
|
||||
type mockAlbumInfoAgent struct {
|
||||
mock.Mock
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
|
||||
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
|
||||
m := new(mockAlbumInfoAgent)
|
||||
m.On("AgentName").Return("mockAlbum").Maybe()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interfaces
|
||||
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
362
core/external/provider_artistimage_test.go
vendored
Normal file
362
core/external/provider_artistimage_test.go
vendored
Normal file
@@ -0,0 +1,362 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - ArtistImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockImageAgent *mockArtistImageAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockImage" // Configure only the mock agent
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockImageAgent = newMockArtistImageAgent()
|
||||
|
||||
// Use the mockAgents from helper, setting the specific agent
|
||||
agentsCombined = &mockAgents{
|
||||
imageAgent: mockImageAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks for successful Get calls
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe()
|
||||
// Default mock for non-existent entities
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
|
||||
// Default successful image agent response
|
||||
mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Maybe()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
mockArtistRepo.AssertExpectations(GinkgoT())
|
||||
mockAlbumRepo.AssertExpectations(GinkgoT())
|
||||
mockMediaFileRepo.AssertExpectations(GinkgoT())
|
||||
mockImageAgent.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the artist is not found in the DB", func() {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "not-found")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
agentErr := errors.New("agent failure")
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled before agent call", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(context.Background())
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) {
|
||||
cancelCtx() // Cancel context *during* the DB call simulation
|
||||
}).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(cctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
})
|
||||
|
||||
It("derives artist ID from MediaFile ID", func() {
|
||||
// Arrange: Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("derives artist ID from Album ID", func() {
|
||||
// Arrange: Add mock for the initial GetEntityByID lookup
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "album-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if derived artist is not found", func() {
|
||||
// Arrange
|
||||
// Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once()
|
||||
// Add expectation for the recursive GetEntityByID call for the MediaFileRepo
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
// The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/medium.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
Context("Unicode handling in artist names", func() {
|
||||
var artistWithEnDash *model.Artist
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalArtistName = "Run–D.M.C." // Artist name with en dash
|
||||
normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in artist name like "Run–D.M.C."
|
||||
artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
|
||||
|
||||
// Mock the image agent to return an image for the artist
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/rundmc.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
It("preserves Unicode characters in artist names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockArtistImageAgent implementation using testify/mock
|
||||
// This remains local as it's specific to testing the ArtistImage functionality
|
||||
type mockArtistImageAgent struct {
|
||||
mock.Mock
|
||||
agents.ArtistImageRetriever // Embed interface
|
||||
}
|
||||
|
||||
// Constructor for the mock agent
|
||||
func newMockArtistImageAgent() *mockArtistImageAgent {
|
||||
mock := new(mockArtistImageAgent)
|
||||
// Set default AgentName if needed, although usually called via mockAgents
|
||||
mock.On("AgentName").Return("mockImage").Maybe()
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid)
|
||||
// Need careful type assertion for potentially nil slice
|
||||
var res []agents.ExternalImage
|
||||
if args.Get(0) != nil {
|
||||
res = args.Get(0).([]agents.ExternalImage)
|
||||
}
|
||||
return res, args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interface
|
||||
var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil)
|
||||
196
core/external/provider_artistradio_test.go
vendored
Normal file
196
core/external/provider_artistradio_test.go
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - ArtistRadio", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var mockAgent *mockSimilarArtistAgent
|
||||
var mockTopAgent agents.ArtistTopSongsRetriever
|
||||
var mockSimilarAgent agents.ArtistSimilarRetriever
|
||||
var agentsCombined Agents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
It("returns similar songs from main artist and similar artists", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
||||
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
similarAgentsResp := []agents.Artist{
|
||||
{Name: "Similar Artist", MBID: "similar-mbid"},
|
||||
}
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(similarAgentsResp, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song Three", MBID: "mbid-3"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3))
|
||||
for _, song := range songs {
|
||||
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when artist is not found", func() {
|
||||
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
|
||||
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns songs from main artist when GetSimilarArtists returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(nil, errors.New("error getting similar artists")).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("returns empty list when GetArtistTopSongs returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, errors.New("error getting top songs")).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("respects count parameter", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
||||
})
|
||||
})
|
||||
274
core/external/provider_topsongs_test.go
vendored
Normal file
274
core/external/provider_topsongs_test.go
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - TopSongs", func() {
|
||||
var (
|
||||
p Provider
|
||||
artistRepo *mockArtistRepo // From provider_helper_test.go
|
||||
mediaFileRepo *mockMediaFileRepo // From provider_helper_test.go
|
||||
ag *mockAgents // Consolidated mock from export_test.go
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo() // Use helper mock
|
||||
mediaFileRepo = newMockMediaFileRepo() // Use helper mock
|
||||
|
||||
// Configure tests.MockDataStore to use the testify/mock-based repos
|
||||
ds := &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
ag = new(mockAgents)
|
||||
|
||||
p = NewProvider(ds, ag)
|
||||
})
|
||||
|
||||
It("returns top songs for a known artist", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks (both returned in a single query)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns nil for an unknown artist", func() {
|
||||
// Mock artist not found
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Unknown Artist", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when the agent returns an error", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent error
|
||||
agentErr := errors.New("agent error")
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(agentErr))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the agent returns ErrNotFound", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent ErrNotFound
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if count is less than available top songs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response (only need 1 for the test)
|
||||
agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching track
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if fewer matching tracks are found", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks (only find song 1 on bulk query)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when context is canceled during agent call", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Setup context that will be canceled
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Mock agent call to return context canceled error
|
||||
ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once()
|
||||
|
||||
cancel() // Cancel the context before calling
|
||||
songs, err := p.TopSongs(canceledCtx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("falls back to title matching when MbzRecordingID is missing", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response with songs that have NO MBID (empty string)
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: ""}, // No MBID, should fall back to title matching
|
||||
{Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Since there are no MBIDs, loadTracksByMBID should not make any database call
|
||||
// loadTracksByTitle should make a database call for title matching
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("combines MBID and title matching when some songs have missing MbzRecordingID", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response with mixed MBID availability
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"}, // Has MBID, should match by MBID
|
||||
{Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock the MBID query (finds song1 by MBID)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
// Mock the title fallback query (finds song2 by title)
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1")) // Found by MBID
|
||||
Expect(songs[1].ID).To(Equal("song-2")) // Found by title
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("only returns requested count when provider returns additional items", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks (both returned in a single query)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
167
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
167
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockAlbumRepo *tests.MockAlbumRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||
})
|
||||
|
||||
It("returns error when album is not found", func() {
|
||||
album, err := p.UpdateAlbumInfo(ctx, "al-not-found")
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(album).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("populates info when album exists but has no external info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-existing",
|
||||
Name: "Test Album",
|
||||
AlbumArtist: "Test Artist",
|
||||
MbzAlbumID: "mbid-album",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedInfo := &agents.AlbumInfo{
|
||||
URL: "http://example.com/album",
|
||||
Description: "Album Description",
|
||||
}
|
||||
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil)
|
||||
ag.On("GetAlbumImages", ctx, "Test Album", "Test Artist", "mbid-album").Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 300},
|
||||
{URL: "http://example.com/medium.jpg", Size: 200},
|
||||
{URL: "http://example.com/small.jpg", Size: 100},
|
||||
}, nil)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(updatedAlbum.ID).To(Equal("al-existing"))
|
||||
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
|
||||
Expect(updatedAlbum.Description).To(Equal("Album Description"))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when album exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-cached",
|
||||
Name: "Cached Album",
|
||||
AlbumArtist: "Cached Artist",
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-cached")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevAlbumInfoTimeToLive * 2)
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-expired",
|
||||
Name: "Expired Album",
|
||||
AlbumArtist: "Expired Artist",
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-expired")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when agent fails to get album info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-error",
|
||||
Name: "Agent Error Album",
|
||||
AlbumArtist: "Agent Error Artist",
|
||||
MbzAlbumID: "mbid-agent-error",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedErr := errors.New("agent communication failed")
|
||||
ag.On("GetAlbumInfo", ctx, "Agent Error Album", "Agent Error Artist", "mbid-agent-error").Return(nil, expectedErr)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-error")
|
||||
|
||||
Expect(err).To(MatchError(expectedErr))
|
||||
Expect(updatedAlbum).To(BeNil())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns original album when agent returns ErrNotFound", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-notfound",
|
||||
Name: "Agent NotFound Album",
|
||||
AlbumArtist: "Agent NotFound Artist",
|
||||
MbzAlbumID: "mbid-agent-notfound",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
ag.On("GetAlbumInfo", ctx, "Agent NotFound Album", "Agent NotFound Artist", "mbid-agent-notfound").Return(nil, agents.ErrNotFound)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-notfound")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil())
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockArtistRepo *tests.MockArtistRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
})
|
||||
|
||||
It("returns error when artist is not found", func() {
|
||||
artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(artist).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists")
|
||||
})
|
||||
|
||||
It("populates info when artist exists but has no external info", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-existing",
|
||||
Name: "Test Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedMBID := "mbid-artist-123"
|
||||
expectedBio := "Artist Bio"
|
||||
expectedURL := "http://artist.url"
|
||||
expectedImages := []agents.ExternalImage{
|
||||
{URL: "http://large.jpg", Size: 300},
|
||||
{URL: "http://medium.jpg", Size: 200},
|
||||
{URL: "http://small.jpg", Size: 100},
|
||||
}
|
||||
rawSimilar := []agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid-similar-1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid-similar-2"},
|
||||
{Name: "Similar Artist 3", MBID: "mbid-similar-3"},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once()
|
||||
ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||
ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-existing"))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID))
|
||||
Expect(updatedArtist.Biography).To(Equal("Artist Bio"))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg"))
|
||||
Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg"))
|
||||
Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg"))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2"))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when artist exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-cached",
|
||||
Name: "Cached Artist",
|
||||
MbzArtistID: "mbid-cached",
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl))
|
||||
Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("includes non-present similar artists when includeNotPresent is true", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
{ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(3))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw"))
|
||||
Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup"))
|
||||
})
|
||||
|
||||
It("updates ArtistInfo even if an optional agent call fails", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-agent-fail",
|
||||
Name: "Agent Fail Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedErr := errors.New("agent MBID failed")
|
||||
ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
228
core/ffmpeg/ffmpeg.go
Normal file
228
core/ffmpeg/ffmpeg.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
type FFmpeg interface {
|
||||
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
|
||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
Probe(ctx context.Context, files []string) (string, error)
|
||||
CmdPath() (string, error)
|
||||
IsAvailable() bool
|
||||
Version() string
|
||||
}
|
||||
|
||||
func New() FFmpeg {
|
||||
return &ffmpeg{}
|
||||
}
|
||||
|
||||
const (
|
||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
||||
)
|
||||
|
||||
type ffmpeg struct{}
|
||||
|
||||
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First make sure the file exists
|
||||
if err := fileExists(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(command, path, maxBitRate, offset)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First make sure the file exists
|
||||
if err := fileExists(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func fileExists(path string) error {
|
||||
s, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.IsDir() {
|
||||
return fmt.Errorf("'%s' is a directory", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
||||
if _, err := ffmpegCmd(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := createProbeCommand(probeCmd, files)
|
||||
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
||||
output, _ := cmd.CombinedOutput()
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func (e *ffmpeg) CmdPath() (string, error) {
|
||||
return ffmpegCmd()
|
||||
}
|
||||
|
||||
func (e *ffmpeg) IsAvailable() bool {
|
||||
_, err := ffmpegCmd()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Version executes ffmpeg -version and extracts the version from the output.
|
||||
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
||||
func (e *ffmpeg) Version() string {
|
||||
cmd, err := ffmpegCmd()
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
parts := strings.Split(string(out), " ")
|
||||
if len(parts) < 3 {
|
||||
return "N/A"
|
||||
}
|
||||
return parts[2]
|
||||
}
|
||||
|
||||
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
||||
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
||||
j := &ffCmd{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go j.wait()
|
||||
return j, nil
|
||||
}
|
||||
|
||||
type ffCmd struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *ffCmd) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting cmd: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *ffCmd) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||
var args []string
|
||||
for _, s := range fixCmd(cmd) {
|
||||
if strings.Contains(s, "%s") {
|
||||
s = strings.ReplaceAll(s, "%s", path)
|
||||
args = append(args, s)
|
||||
if offset > 0 && !strings.Contains(cmd, "%t") {
|
||||
args = append(args, "-ss", strconv.Itoa(offset))
|
||||
}
|
||||
} else {
|
||||
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
||||
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func createProbeCommand(cmd string, inputs []string) []string {
|
||||
var args []string
|
||||
for _, s := range fixCmd(cmd) {
|
||||
if s == "%s" {
|
||||
for _, inp := range inputs {
|
||||
args = append(args, "-i", inp)
|
||||
}
|
||||
} else {
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func fixCmd(cmd string) []string {
|
||||
split := strings.Fields(cmd)
|
||||
cmdPath, _ := ffmpegCmd()
|
||||
for i, s := range split {
|
||||
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
||||
split[i] = cmdPath
|
||||
}
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
func ffmpegCmd() (string, error) {
|
||||
ffOnce.Do(func() {
|
||||
if conf.Server.FFmpegPath != "" {
|
||||
ffmpegPath = conf.Server.FFmpegPath
|
||||
ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath)
|
||||
} else {
|
||||
ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg")
|
||||
if errors.Is(ffmpegErr, exec.ErrDot) {
|
||||
log.Trace("ffmpeg found in current folder '.'")
|
||||
ffmpegPath, ffmpegErr = exec.LookPath("./ffmpeg")
|
||||
}
|
||||
}
|
||||
if ffmpegErr == nil {
|
||||
log.Info("Found ffmpeg", "path", ffmpegPath)
|
||||
return
|
||||
}
|
||||
})
|
||||
return ffmpegPath, ffmpegErr
|
||||
}
|
||||
|
||||
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
|
||||
var (
|
||||
ffOnce sync.Once
|
||||
ffmpegPath string
|
||||
ffmpegErr error
|
||||
)
|
||||
166
core/ffmpeg/ffmpeg_test.go
Normal file
166
core/ffmpeg/ffmpeg_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
sync "sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestFFmpeg(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "FFmpeg Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("ffmpeg", func() {
|
||||
BeforeEach(func() {
|
||||
_, _ = ffmpegCmd()
|
||||
ffmpegPath = "ffmpeg"
|
||||
ffmpegErr = nil
|
||||
})
|
||||
Describe("createFFmpegCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
It("handles extra spaces in the command string", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
Context("when command has time offset param", func() {
|
||||
It("creates a valid command line with offset", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
|
||||
})
|
||||
|
||||
})
|
||||
Context("when command does not have time offset param", func() {
|
||||
It("adds time offset after the input file name", func() {
|
||||
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createProbeCommand", func() {
|
||||
It("creates a valid command line", func() {
|
||||
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
|
||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
})
|
||||
|
||||
When("ffmpegPath is set", func() {
|
||||
It("returns the correct ffmpeg path", func() {
|
||||
ffmpegPath = "/usr/bin/ffmpeg"
|
||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
||||
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
It("returns the correct ffmpeg path with spaces", func() {
|
||||
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
|
||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FFmpeg", func() {
|
||||
Context("when FFmpeg is available", func() {
|
||||
var ff FFmpeg
|
||||
|
||||
BeforeEach(func() {
|
||||
ffOnce = sync.Once{}
|
||||
ff = New()
|
||||
// Skip if FFmpeg is not available
|
||||
if !ff.IsAvailable() {
|
||||
Skip("FFmpeg not available on this system")
|
||||
}
|
||||
})
|
||||
|
||||
It("should interrupt transcoding when context is cancelled", func() {
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use a command that generates audio indefinitely
|
||||
// -f lavfi uses FFmpeg's built-in audio source
|
||||
// -t 0 means no time limit (runs forever)
|
||||
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
|
||||
|
||||
// The input file is not used here, but we need to provide a valid path to the Transcode function
|
||||
stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
// Read some data first to ensure FFmpeg is running
|
||||
buf := make([]byte, 1024)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Next read should fail due to cancelled context
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle immediate context cancellation", func() {
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// This should fail immediately
|
||||
_, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with mock process behavior", func() {
|
||||
var longRunningCmd string
|
||||
BeforeEach(func() {
|
||||
// Use a long-running command for testing cancellation
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
// Use PowerShell's Start-Sleep
|
||||
ffmpegPath = "powershell"
|
||||
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
|
||||
default:
|
||||
// Use sleep on Unix-like systems
|
||||
ffmpegPath = "sleep"
|
||||
longRunningCmd = "sleep 10"
|
||||
}
|
||||
})
|
||||
|
||||
It("should terminate the underlying process when context is cancelled", func() {
|
||||
ff := New()
|
||||
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start a process that will run for a while
|
||||
stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
// Give the process time to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel the context
|
||||
cancel()
|
||||
|
||||
// Try to read from the stream, which should fail
|
||||
buf := make([]byte, 100)
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
|
||||
|
||||
// Verify the stream is closed by attempting another read
|
||||
_, err = stream.Read(buf)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
51
core/inspect.go
Normal file
51
core/inspect.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
type InspectOutput struct {
|
||||
File string `json:"file"`
|
||||
RawTags model.RawTags `json:"rawTags"`
|
||||
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
|
||||
}
|
||||
|
||||
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
|
||||
path, file := filepath.Split(filePath)
|
||||
|
||||
s, err := storage.For(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fs, err := s.FS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := fs.ReadTags(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tag, ok := tags[file]
|
||||
if !ok {
|
||||
log.Error("Could not get tags for path", "path", filePath)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
md := metadata.New(path, tag)
|
||||
result := &InspectOutput{
|
||||
File: filePath,
|
||||
RawTags: tags[file].Tags,
|
||||
MappedTags: P(md.ToMediaFile(libraryId, folderId)),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
407
core/library.go
Normal file
407
core/library.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// Watcher interface for managing file system watchers
|
||||
type Watcher interface {
|
||||
Watch(ctx context.Context, lib *model.Library) error
|
||||
StopWatching(ctx context.Context, libraryID int) error
|
||||
}
|
||||
|
||||
// Library provides business logic for library management and user-library associations
|
||||
type Library interface {
|
||||
GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
|
||||
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
|
||||
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
|
||||
|
||||
NewRepository(ctx context.Context) rest.Repository
|
||||
}
|
||||
|
||||
type libraryService struct {
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
}
|
||||
|
||||
// NewLibrary creates a new Library service
|
||||
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
|
||||
return &libraryService{
|
||||
ds: ds,
|
||||
scanner: scanner,
|
||||
watcher: watcher,
|
||||
broker: broker,
|
||||
}
|
||||
}
|
||||
|
||||
// User-library association operations
|
||||
|
||||
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
|
||||
// Verify user exists
|
||||
if _, err := s.ds.User(ctx).Get(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.ds.User(ctx).GetUserLibraries(userID)
|
||||
}
|
||||
|
||||
func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
|
||||
// Verify user exists
|
||||
user, err := s.ds.User(ctx).Get(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin users get all libraries automatically - don't allow manual assignment
|
||||
if user.IsAdmin {
|
||||
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
|
||||
}
|
||||
|
||||
// Regular users must have at least one library
|
||||
if len(libraryIDs) == 0 {
|
||||
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
|
||||
}
|
||||
|
||||
// Validate all library IDs exist
|
||||
if len(libraryIDs) > 0 {
|
||||
if err := s.validateLibraryIDs(ctx, libraryIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Set user libraries
|
||||
err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error setting user libraries: %w", err)
|
||||
}
|
||||
|
||||
// Send refresh event to all clients
|
||||
event := &events.RefreshResource{}
|
||||
libIDs := slice.Map(libraryIDs, func(id int) string { return strconv.Itoa(id) })
|
||||
event = event.With("user", userID).With("library", libIDs...)
|
||||
s.broker.SendBroadcastMessage(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
|
||||
user, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("user not found in context")
|
||||
}
|
||||
|
||||
// Admin users have access to all libraries
|
||||
if user.IsAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if user has explicit access to this library
|
||||
libraries, err := s.ds.User(ctx).GetUserLibraries(userID)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
|
||||
return fmt.Errorf("error checking library access: %w", err)
|
||||
}
|
||||
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == libraryID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID)
|
||||
}
|
||||
|
||||
// REST repository wrapper
|
||||
|
||||
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
||||
repo := s.ds.Library(ctx)
|
||||
wrapper := &libraryRepositoryWrapper{
|
||||
ctx: ctx,
|
||||
LibraryRepository: repo,
|
||||
Repository: repo.(rest.Repository),
|
||||
ds: s.ds,
|
||||
scanner: s.scanner,
|
||||
watcher: s.watcher,
|
||||
broker: s.broker,
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
|
||||
type libraryRepositoryWrapper struct {
|
||||
rest.Repository
|
||||
model.LibraryRepository
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
lib := entity.(*model.Library)
|
||||
if err := r.validateLibrary(lib); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := r.LibraryRepository.Put(lib)
|
||||
if err != nil {
|
||||
return "", r.mapError(err)
|
||||
}
|
||||
|
||||
// Start watcher and trigger scan after successful library creation
|
||||
if r.watcher != nil {
|
||||
if err := r.watcher.Watch(r.ctx, lib); err != nil {
|
||||
log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(lib, "new")
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
if r.broker != nil {
|
||||
event := &events.RefreshResource{}
|
||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
|
||||
log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
|
||||
}
|
||||
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
lib := entity.(*model.Library)
|
||||
libID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid library ID: %s", id)
|
||||
}
|
||||
|
||||
lib.ID = libID
|
||||
if err := r.validateLibrary(lib); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the original library to check if path changed
|
||||
originalLib, err := r.Get(libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
pathChanged := originalLib.Path != lib.Path
|
||||
|
||||
err = r.LibraryRepository.Put(lib)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
// Restart watcher and trigger scan if path was updated
|
||||
if pathChanged {
|
||||
if r.watcher != nil {
|
||||
if err := r.watcher.Watch(r.ctx, lib); err != nil {
|
||||
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(lib, "updated")
|
||||
}
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
if r.broker != nil {
|
||||
event := &events.RefreshResource{}
|
||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
||||
log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||
libID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return &rest.ValidationError{Errors: map[string]string{
|
||||
"id": "invalid library ID format",
|
||||
}}
|
||||
}
|
||||
|
||||
// Get library info before deletion for logging
|
||||
lib, err := r.Get(libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
err = r.LibraryRepository.Delete(libID)
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
|
||||
if r.watcher != nil {
|
||||
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
|
||||
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if r.scanner != nil {
|
||||
go r.triggerScan(lib, "deleted")
|
||||
}
|
||||
|
||||
// Send library refresh event to all clients
|
||||
if r.broker != nil {
|
||||
event := &events.RefreshResource{}
|
||||
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
|
||||
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (r *libraryRepositoryWrapper) mapError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// Handle database constraint violations.
|
||||
// TODO: Being tied to react-admin translations is not ideal, but this will probably go away with the new UI/API
|
||||
if strings.Contains(errStr, "UNIQUE constraint failed") {
|
||||
if strings.Contains(errStr, "library.name") {
|
||||
return &rest.ValidationError{Errors: map[string]string{"name": "ra.validation.unique"}}
|
||||
}
|
||||
if strings.Contains(errStr, "library.path") {
|
||||
return &rest.ValidationError{Errors: map[string]string{"path": "ra.validation.unique"}}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
|
||||
validationErrors := make(map[string]string)
|
||||
|
||||
if library.Name == "" {
|
||||
validationErrors["name"] = "ra.validation.required"
|
||||
}
|
||||
|
||||
if library.Path == "" {
|
||||
validationErrors["path"] = "ra.validation.required"
|
||||
} else {
|
||||
// Validate path format and accessibility
|
||||
if err := r.validateLibraryPath(library); err != nil {
|
||||
validationErrors["path"] = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
if len(validationErrors) > 0 {
|
||||
return &rest.ValidationError{Errors: validationErrors}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
|
||||
// Validate path format
|
||||
if !filepath.IsAbs(library.Path) {
|
||||
return fmt.Errorf("library path must be absolute")
|
||||
}
|
||||
|
||||
// Clean the path to normalize it
|
||||
cleanPath := filepath.Clean(library.Path)
|
||||
library.Path = cleanPath
|
||||
|
||||
// Check if path exists and is accessible using storage abstraction
|
||||
fileStore, err := storage.For(library.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid storage scheme: %w", err)
|
||||
}
|
||||
|
||||
fsys, err := fileStore.FS()
|
||||
if err != nil {
|
||||
log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err)
|
||||
return fmt.Errorf("resources.library.validation.pathInvalid")
|
||||
}
|
||||
|
||||
// Check if root directory exists
|
||||
info, err := fs.Stat(fsys, ".")
|
||||
if err != nil {
|
||||
// Parse the error message to check for "not a directory"
|
||||
log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err)
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "not a directory") ||
|
||||
strings.Contains(errStr, "The directory name is invalid.") {
|
||||
return fmt.Errorf("resources.library.validation.pathNotDirectory")
|
||||
} else if os.IsNotExist(err) {
|
||||
return fmt.Errorf("resources.library.validation.pathNotFound")
|
||||
} else if os.IsPermission(err) {
|
||||
return fmt.Errorf("resources.library.validation.pathNotAccessible")
|
||||
} else {
|
||||
return fmt.Errorf("resources.library.validation.pathInvalid")
|
||||
}
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("resources.library.validation.pathNotDirectory")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
|
||||
if len(libraryIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use CountAll to efficiently validate library IDs exist
|
||||
count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"id": libraryIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error validating library IDs: %w", err)
|
||||
}
|
||||
|
||||
if int(count) != len(libraryIDs) {
|
||||
return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
|
||||
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
||||
start := time.Now()
|
||||
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
|
||||
if err != nil {
|
||||
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
||||
} else {
|
||||
log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
||||
}
|
||||
}
|
||||
958
core/library_test.go
Normal file
958
core/library_test.go
Normal file
@@ -0,0 +1,958 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// These tests require the local storage adapter and the taglib extractor to be registered.
|
||||
var _ = Describe("Library Service", func() {
|
||||
var service core.Library
|
||||
var ds *tests.MockDataStore
|
||||
var libraryRepo *tests.MockLibraryRepo
|
||||
var userRepo *tests.MockedUserRepo
|
||||
var ctx context.Context
|
||||
var tempDir string
|
||||
var scanner *tests.MockScanner
|
||||
var watcherManager *mockWatcherManager
|
||||
var broker *mockEventBroker
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
ds = &tests.MockDataStore{}
|
||||
libraryRepo = &tests.MockLibraryRepo{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
ds.MockedLibrary = libraryRepo
|
||||
ds.MockedUser = userRepo
|
||||
|
||||
// Create a mock scanner that tracks calls
|
||||
scanner = tests.NewMockScanner()
|
||||
// Create a mock watcher manager
|
||||
watcherManager = &mockWatcherManager{
|
||||
libraryStates: make(map[int]model.Library),
|
||||
}
|
||||
// Create a mock event broker
|
||||
broker = &mockEventBroker{}
|
||||
service = core.NewLibrary(ds, scanner, watcherManager, broker)
|
||||
ctx = context.Background()
|
||||
|
||||
// Create a temporary directory for testing valid paths
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "navidrome-library-test-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Library CRUD Operations", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
It("creates a new library successfully", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
|
||||
Expect(libraryRepo.Data[1].Path).To(Equal(tempDir))
|
||||
})
|
||||
|
||||
It("fails when library name is empty", func() {
|
||||
library := &model.Library{Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||
})
|
||||
|
||||
It("fails when library path is empty", func() {
|
||||
library := &model.Library{Name: "Test"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||
})
|
||||
|
||||
It("fails when library path is not absolute", func() {
|
||||
library := &model.Library{Name: "Test", Path: "relative/path"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
|
||||
})
|
||||
|
||||
Context("Database constraint violations", func() {
|
||||
BeforeEach(func() {
|
||||
// Set up an existing library that will cause constraint violations
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Existing Library", Path: tempDir},
|
||||
})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Reset custom PutFn after each test
|
||||
libraryRepo.PutFn = nil
|
||||
})
|
||||
|
||||
It("handles name uniqueness constraint violation from database", func() {
|
||||
// Create the directory that will be used for the test
|
||||
otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(otherTempDir) })
|
||||
|
||||
// Try to create another library with the same name
|
||||
library := &model.Library{ID: 2, Name: "Existing Library", Path: otherTempDir}
|
||||
|
||||
// Mock the repository to return a UNIQUE constraint error
|
||||
libraryRepo.PutFn = func(library *model.Library) error {
|
||||
return errors.New("UNIQUE constraint failed: library.name")
|
||||
}
|
||||
|
||||
_, err = repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
|
||||
})
|
||||
|
||||
It("handles path uniqueness constraint violation from database", func() {
|
||||
// Try to create another library with the same path
|
||||
library := &model.Library{ID: 2, Name: "Different Library", Path: tempDir}
|
||||
|
||||
// Mock the repository to return a UNIQUE constraint error
|
||||
libraryRepo.PutFn = func(library *model.Library) error {
|
||||
return errors.New("UNIQUE constraint failed: library.path")
|
||||
}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
BeforeEach(func() {
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
})
|
||||
|
||||
It("updates an existing library successfully", func() {
|
||||
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(newTempDir) })
|
||||
|
||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||
|
||||
err = repo.Update("1", library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
|
||||
Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir))
|
||||
})
|
||||
|
||||
It("fails when library doesn't exist", func() {
|
||||
// Create a unique temporary directory to avoid path conflicts
|
||||
uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(uniqueTempDir) })
|
||||
|
||||
library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}
|
||||
|
||||
err = repo.Update("999", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("fails when library name is empty", func() {
|
||||
library := &model.Library{ID: 1, Path: tempDir}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
|
||||
})
|
||||
|
||||
It("cleans and normalizes the path on update", func() {
|
||||
unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
|
||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
|
||||
})
|
||||
|
||||
It("allows updating library with same name (no change)", func() {
|
||||
// Set up a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Update the library keeping the same name (should be allowed)
|
||||
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows updating library with same path (no change)", func() {
|
||||
// Set up a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Update the library keeping the same path (should be allowed)
|
||||
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
Context("Database constraint violations during update", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset any custom PutFn from previous tests
|
||||
libraryRepo.PutFn = nil
|
||||
})
|
||||
|
||||
It("handles name uniqueness constraint violation during update", func() {
|
||||
// Create additional temp directory for the test
|
||||
otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(otherTempDir) })
|
||||
|
||||
// Set up two libraries
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Library One", Path: tempDir},
|
||||
{ID: 2, Name: "Library Two", Path: otherTempDir},
|
||||
})
|
||||
|
||||
// Mock database constraint violation
|
||||
libraryRepo.PutFn = func(library *model.Library) error {
|
||||
return errors.New("UNIQUE constraint failed: library.name")
|
||||
}
|
||||
|
||||
// Try to update library 2 to have the same name as library 1
|
||||
library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}
|
||||
|
||||
err = repo.Update("2", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
|
||||
})
|
||||
|
||||
It("handles path uniqueness constraint violation during update", func() {
|
||||
// Create additional temp directory for the test
|
||||
otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(otherTempDir) })
|
||||
|
||||
// Set up two libraries
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Library One", Path: tempDir},
|
||||
{ID: 2, Name: "Library Two", Path: otherTempDir},
|
||||
})
|
||||
|
||||
// Mock database constraint violation
|
||||
libraryRepo.PutFn = func(library *model.Library) error {
|
||||
return errors.New("UNIQUE constraint failed: library.path")
|
||||
}
|
||||
|
||||
// Try to update library 2 to have the same path as library 1
|
||||
library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}
|
||||
|
||||
err = repo.Update("2", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Path Validation", func() {
|
||||
Context("Create operation", func() {
|
||||
It("fails when path is not absolute", func() {
|
||||
library := &model.Library{Name: "Test", Path: "relative/path"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
|
||||
})
|
||||
|
||||
It("fails when path does not exist", func() {
|
||||
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
||||
library := &model.Library{Name: "Test", Path: nonExistentPath}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
|
||||
})
|
||||
|
||||
It("fails when path is a file instead of directory", func() {
|
||||
testFile := filepath.Join(tempDir, "testfile.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
library := &model.Library{Name: "Test", Path: testFile}
|
||||
|
||||
_, err = repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
|
||||
})
|
||||
|
||||
It("fails when path is not accessible due to permissions", func() {
|
||||
Skip("Permission tests are environment-dependent and may fail in CI")
|
||||
// This test is skipped because creating a directory with no read permissions
|
||||
// is complex and may not work consistently across different environments
|
||||
})
|
||||
|
||||
It("handles multiple validation errors", func() {
|
||||
library := &model.Library{Name: "", Path: "relative/path"}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors).To(HaveKey("name"))
|
||||
Expect(validationErr.Errors).To(HaveKey("path"))
|
||||
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
|
||||
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Update operation", func() {
|
||||
BeforeEach(func() {
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
})
|
||||
|
||||
It("fails when updated path is not absolute", func() {
|
||||
library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
|
||||
})
|
||||
|
||||
It("allows updating library with same name (no change)", func() {
|
||||
// Set up a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Update the library keeping the same name (should be allowed)
|
||||
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("fails when updated path does not exist", func() {
|
||||
nonExistentPath := filepath.Join(tempDir, "nonexistent")
|
||||
library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
|
||||
})
|
||||
|
||||
It("fails when updated path is a file instead of directory", func() {
|
||||
testFile := filepath.Join(tempDir, "updatefile.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
library := &model.Library{ID: 1, Name: "Test", Path: testFile}
|
||||
|
||||
err = repo.Update("1", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
|
||||
})
|
||||
|
||||
It("handles multiple validation errors on update", func() {
|
||||
// Try to update with empty name and invalid path
|
||||
library := &model.Library{ID: 1, Name: "", Path: "relative/path"}
|
||||
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *rest.ValidationError
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors).To(HaveKey("name"))
|
||||
Expect(validationErr.Errors).To(HaveKey("path"))
|
||||
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
|
||||
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
BeforeEach(func() {
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Library to Delete", Path: tempDir},
|
||||
})
|
||||
})
|
||||
|
||||
It("deletes an existing library successfully", func() {
|
||||
err := repo.Delete("1")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(libraryRepo.Data).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("fails when library doesn't exist", func() {
|
||||
err := repo.Delete("999")
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("User-Library Association Operations", func() {
|
||||
var regularUser, adminUser *model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
regularUser = &model.User{ID: "user1", UserName: "regular", IsAdmin: false}
|
||||
adminUser = &model.User{ID: "admin1", UserName: "admin", IsAdmin: true}
|
||||
|
||||
userRepo.Data = map[string]*model.User{
|
||||
"regular": regularUser,
|
||||
"admin": adminUser,
|
||||
}
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Library 1", Path: "/music1"},
|
||||
{ID: 2, Name: "Library 2", Path: "/music2"},
|
||||
{ID: 3, Name: "Library 3", Path: "/music3"},
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetUserLibraries", func() {
|
||||
It("returns user's libraries", func() {
|
||||
userRepo.UserLibraries = map[string][]int{
|
||||
"user1": {1},
|
||||
}
|
||||
|
||||
result, err := service.GetUserLibraries(ctx, "user1")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].ID).To(Equal(1))
|
||||
})
|
||||
|
||||
It("fails when user doesn't exist", func() {
|
||||
_, err := service.GetUserLibraries(ctx, "nonexistent")
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetUserLibraries", func() {
|
||||
It("sets libraries for regular user successfully", func() {
|
||||
err := service.SetUserLibraries(ctx, "user1", []int{1, 2})
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
libraries := userRepo.UserLibraries["user1"]
|
||||
Expect(libraries).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("fails when user doesn't exist", func() {
|
||||
err := service.SetUserLibraries(ctx, "nonexistent", []int{1})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("fails when trying to set libraries for admin user", func() {
|
||||
err := service.SetUserLibraries(ctx, "admin1", []int{1})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cannot manually assign libraries to admin users"))
|
||||
})
|
||||
|
||||
It("fails when no libraries provided for regular user", func() {
|
||||
err := service.SetUserLibraries(ctx, "user1", []int{})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("at least one library must be assigned to non-admin users"))
|
||||
})
|
||||
|
||||
It("fails when library doesn't exist", func() {
|
||||
err := service.SetUserLibraries(ctx, "user1", []int{999})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
|
||||
})
|
||||
|
||||
It("fails when some libraries don't exist", func() {
|
||||
err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateLibraryAccess", func() {
|
||||
Context("admin user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, *adminUser)
|
||||
})
|
||||
|
||||
It("allows access to any library", func() {
|
||||
err := service.ValidateLibraryAccess(ctx, "admin1", 1)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("regular user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = request.WithUser(ctx, *regularUser)
|
||||
userRepo.UserLibraries = map[string][]int{
|
||||
"user1": {1},
|
||||
}
|
||||
})
|
||||
|
||||
It("allows access to user's libraries", func() {
|
||||
err := service.ValidateLibraryAccess(ctx, "user1", 1)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies access to libraries user doesn't have", func() {
|
||||
err := service.ValidateLibraryAccess(ctx, "user1", 2)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("user does not have access to library 2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("no user in context", func() {
|
||||
It("fails with user not found error", func() {
|
||||
err := service.ValidateLibraryAccess(ctx, "user1", 1)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("user not found in context"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scan Triggering", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
})
|
||||
|
||||
It("triggers scan when creating a new library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
Eventually(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
calls := scanner.GetScanAllCalls()
|
||||
Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
|
||||
})
|
||||
|
||||
It("triggers scan when updating library path", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Create a new temporary directory for the update
|
||||
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(newTempDir) })
|
||||
|
||||
// Update the library with a new path
|
||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||
err = repo.Update("1", library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
Eventually(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
calls := scanner.GetScanAllCalls()
|
||||
Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
|
||||
})
|
||||
|
||||
It("does not trigger scan when updating library without path change", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Update the library name only (same path)
|
||||
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait a bit to ensure no scan was triggered
|
||||
Consistently(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("does not trigger scan when library creation fails", func() {
|
||||
// Try to create library with invalid data (empty name)
|
||||
library := &model.Library{Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since creation failed
|
||||
Consistently(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("does not trigger scan when library update fails", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Try to update with invalid data (empty name)
|
||||
library := &model.Library{ID: 1, Name: "", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since update failed
|
||||
Consistently(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("triggers scan when deleting a library", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Library to Delete", Path: tempDir},
|
||||
})
|
||||
|
||||
// Delete the library
|
||||
err := repo.Delete("1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Wait briefly for the goroutine to complete
|
||||
Eventually(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
// Verify scan was called with correct parameters
|
||||
calls := scanner.GetScanAllCalls()
|
||||
Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
|
||||
})
|
||||
|
||||
It("does not trigger scan when library deletion fails", func() {
|
||||
// Try to delete a non-existent library
|
||||
err := repo.Delete("999")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Ensure no scan was triggered since deletion failed
|
||||
Consistently(func() int {
|
||||
return scanner.GetScanAllCallCount()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
Context("Watcher Integration", func() {
|
||||
It("starts watcher when creating a new library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify watcher was started
|
||||
Eventually(func() int {
|
||||
return watcherManager.lenStarted()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1))
|
||||
Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library"))
|
||||
Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir))
|
||||
})
|
||||
|
||||
It("restarts watcher when library path is updated", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Simulate that this library already has a watcher
|
||||
watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir})
|
||||
|
||||
// Create a new temp directory for the update
|
||||
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(newTempDir) })
|
||||
|
||||
// Update library with new path
|
||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
|
||||
err = repo.Update("1", library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify watcher was restarted
|
||||
Eventually(func() int {
|
||||
return watcherManager.lenRestarted()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1))
|
||||
Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir))
|
||||
})
|
||||
|
||||
It("does not restart watcher when only library name is updated", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Update library with same path but different name
|
||||
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify watcher was NOT restarted (since path didn't change)
|
||||
Consistently(func() int {
|
||||
return watcherManager.lenRestarted()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
|
||||
It("stops watcher when library is deleted", func() {
|
||||
// Set up a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
|
||||
err := repo.Delete("1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify watcher was stopped
|
||||
Eventually(func() int {
|
||||
return watcherManager.lenStopped()
|
||||
}, "1s", "10ms").Should(Equal(1))
|
||||
|
||||
Expect(watcherManager.StoppedWatchers[0]).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not stop watcher when library deletion fails", func() {
|
||||
// Set up a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library", Path: tempDir},
|
||||
})
|
||||
|
||||
// Mock deletion to fail by trying to delete non-existent library
|
||||
err := repo.Delete("999")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
// Verify watcher was NOT stopped since deletion failed
|
||||
Consistently(func() int {
|
||||
return watcherManager.lenStopped()
|
||||
}, "100ms", "10ms").Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Event Broadcasting", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
// Clear any events from broker
|
||||
broker.Events = []events.Event{}
|
||||
})
|
||||
|
||||
It("sends refresh event when creating a library", func() {
|
||||
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
|
||||
|
||||
_, err := repo.Save(library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("sends refresh event when updating a library", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Original Library", Path: tempDir},
|
||||
})
|
||||
|
||||
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
|
||||
err := repo.Update("1", library)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("sends refresh event when deleting a library", func() {
|
||||
// First create a library
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||
})
|
||||
|
||||
err := repo.Delete("2")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
|
||||
type mockWatcherManager struct {
|
||||
StartedWatchers []model.Library
|
||||
StoppedWatchers []int
|
||||
RestartedWatchers []model.Library
|
||||
libraryStates map[int]model.Library // Track which libraries we know about
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if we already know about this library ID
|
||||
if _, exists := m.libraryStates[lib.ID]; exists {
|
||||
// This is a restart - the library already existed
|
||||
// Update our tracking and record the restart
|
||||
for i, startedLib := range m.StartedWatchers {
|
||||
if startedLib.ID == lib.ID {
|
||||
m.StartedWatchers[i] = *lib
|
||||
break
|
||||
}
|
||||
}
|
||||
m.RestartedWatchers = append(m.RestartedWatchers, *lib)
|
||||
m.libraryStates[lib.ID] = *lib
|
||||
return nil
|
||||
}
|
||||
|
||||
// This is a new library - first time we're seeing it
|
||||
m.StartedWatchers = append(m.StartedWatchers, *lib)
|
||||
m.libraryStates[lib.ID] = *lib
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.StoppedWatchers = append(m.StoppedWatchers, libraryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockWatcherManager) lenStarted() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.StartedWatchers)
|
||||
}
|
||||
|
||||
func (m *mockWatcherManager) lenStopped() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.StoppedWatchers)
|
||||
}
|
||||
|
||||
func (m *mockWatcherManager) lenRestarted() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.RestartedWatchers)
|
||||
}
|
||||
|
||||
// simulateExistingLibrary simulates the scenario where a library already exists
|
||||
// and has a watcher running (used by tests to set up the initial state)
|
||||
func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.libraryStates[lib.ID] = lib
|
||||
}
|
||||
|
||||
// mockEventBroker provides a mock implementation of events.Broker for testing
|
||||
type mockEventBroker struct {
|
||||
http.Handler
|
||||
Events []events.Event
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Events = append(m.Events, event)
|
||||
}
|
||||
|
||||
func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Events = append(m.Events, event)
|
||||
}
|
||||
37
core/lyrics/lyrics.go
Normal file
37
core/lyrics/lyrics.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
lyricsList, err = fromEmbedded(ctx, mf)
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
return lyricsList, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
17
core/lyrics/lyrics_suite_test.go
Normal file
17
core/lyrics/lyrics_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestLyrics(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Lyrics Suite")
|
||||
}
|
||||
124
core/lyrics/lyrics_test.go
Normal file
124
core/lyrics/lyrics_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package lyrics_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsynced, _ := model.ToLyrics("xxx", badLyrics)
|
||||
embeddedLyrics := model.LyricList{*unsynced}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}
|
||||
|
||||
unsyncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
lyricsJson, _ := json.Marshal(embeddedLyrics)
|
||||
|
||||
mf = model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
Path: "tests/fixtures/test.mp3",
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
|
||||
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
|
||||
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
|
||||
|
||||
Context("Errors", func() {
|
||||
var RegularUserContext = XContext
|
||||
var isRegularUser = os.Getuid() != 0
|
||||
if isRegularUser {
|
||||
RegularUserContext = Context
|
||||
}
|
||||
|
||||
RegularUserContext("run without root permissions", func() {
|
||||
var accessForbiddenFile string
|
||||
|
||||
BeforeEach(func() {
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf.Path = accessForbiddenFile
|
||||
|
||||
DeferCleanup(func() {
|
||||
Expect(f.Close()).To(Succeed())
|
||||
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
})
|
||||
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
51
core/lyrics/sources.go
Normal file
51
core/lyrics/sources.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
)
|
||||
|
||||
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
if mf.Lyrics != "" {
|
||||
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
|
||||
return mf.StructuredLyrics()
|
||||
}
|
||||
|
||||
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
basePath := mf.AbsolutePath()
|
||||
ext := path.Ext(basePath)
|
||||
|
||||
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
|
||||
|
||||
contents, err := ioutils.UTF8ReadFile(externalLyric)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lyrics, err := model.ToLyrics("xxx", string(contents))
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
|
||||
return nil, err
|
||||
} else if lyrics == nil {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
146
core/lyrics/sources_test.go
Normal file
146
core/lyrics/sources_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
Describe("fromEmbedded", func() {
|
||||
It("should return nothing for a media file with no lyrics", func() {
|
||||
mf := model.MediaFile{}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return lyrics for a media file with well-formatted lyrics", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
synced, _ := model.ToLyrics("eng", syncedLyrics)
|
||||
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
|
||||
|
||||
expectedList := model.LyricList{*synced, *unsynced}
|
||||
lyricsJson, err := json.Marshal(expectedList)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
mf := model.MediaFile{
|
||||
Lyrics: string(lyricsJson),
|
||||
}
|
||||
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(Equal(expectedList))
|
||||
})
|
||||
|
||||
It("should return an error if somehow the JSON is bad", func() {
|
||||
mf := model.MediaFile{Lyrics: "["}
|
||||
lyrics, err := fromEmbedded(ctx, &mf)
|
||||
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
Expect(err).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromExternalFile", func() {
|
||||
It("should return nil for lyrics that don't exist", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should return synchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
DisplayArtist: "Rick Astley",
|
||||
DisplayTitle: "That one song",
|
||||
Lang: "eng",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Start: gg.P(int64(18800)),
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: gg.P(int64(22801)),
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Offset: gg.P(int64(-100)),
|
||||
Synced: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should return unsynchronized lyrics from a file", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).To(Equal(model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Value: "You know the rules and so do I",
|
||||
},
|
||||
},
|
||||
Synced: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
|
||||
// The function looks for <basePath-without-ext><suffix>, so we need to pass
|
||||
// a MediaFile with .mp3 path and look for .lrc suffix
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
// The critical assertion: even with BOM, synced should be true
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(1))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0))))
|
||||
Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲"))
|
||||
})
|
||||
|
||||
It("should handle UTF-16 LE encoded LRC files", func() {
|
||||
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
|
||||
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(lyrics).ToNot(BeNil())
|
||||
Expect(lyrics).To(HaveLen(1))
|
||||
|
||||
// UTF-16 should be properly converted to UTF-8
|
||||
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
|
||||
Expect(lyrics[0].Line).To(HaveLen(2))
|
||||
Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800))))
|
||||
Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love"))
|
||||
Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801))))
|
||||
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
|
||||
})
|
||||
})
|
||||
})
|
||||
226
core/maintenance.go
Normal file
226
core/maintenance.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Maintenance interface {
|
||||
// DeleteMissingFiles deletes specific missing files by their IDs
|
||||
DeleteMissingFiles(ctx context.Context, ids []string) error
|
||||
// DeleteAllMissingFiles deletes all files marked as missing
|
||||
DeleteAllMissingFiles(ctx context.Context) error
|
||||
}
|
||||
|
||||
type maintenanceService struct {
|
||||
ds model.DataStore
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewMaintenance(ds model.DataStore) Maintenance {
|
||||
return &maintenanceService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
|
||||
return s.deleteMissing(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
|
||||
return s.deleteMissing(ctx, nil)
|
||||
}
|
||||
|
||||
// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
|
||||
func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
|
||||
// Track affected album IDs before deletion for refresh
|
||||
affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error tracking affected albums for refresh", err)
|
||||
// Don't fail the operation, just log the warning
|
||||
}
|
||||
|
||||
// Delete missing files within a transaction
|
||||
err = s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Run garbage collection to clean up orphaned records
|
||||
if err := s.ds.GC(ctx); err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh statistics in background
|
||||
s.refreshStatsAsync(ctx, affectedAlbumIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
|
||||
// It uses batch queries to minimize database round-trips for efficiency.
|
||||
func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
|
||||
if len(albumIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
|
||||
|
||||
// Process in chunks to avoid query size limits
|
||||
const chunkSize = 100
|
||||
for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
|
||||
if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
|
||||
return fmt.Errorf("refreshing album chunk: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAlbumChunk processes a single chunk of album IDs
|
||||
func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
|
||||
albumRepo := s.ds.Album(ctx)
|
||||
mfRepo := s.ds.MediaFile(ctx)
|
||||
|
||||
// Batch load existing albums
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album.id": albumIDs},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading albums: %w", err)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
albumMap := make(map[string]*model.Album, len(albums))
|
||||
for i := range albums {
|
||||
albumMap[albums[i].ID] = &albums[i]
|
||||
}
|
||||
|
||||
// Batch load all media files for these albums
|
||||
mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"album_id": albumIDs},
|
||||
Sort: "album_id, path",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading media files: %w", err)
|
||||
}
|
||||
|
||||
// Group media files by album ID
|
||||
filesByAlbum := make(map[string]model.MediaFiles)
|
||||
for i := range mediaFiles {
|
||||
albumID := mediaFiles[i].AlbumID
|
||||
filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
|
||||
}
|
||||
|
||||
// Recalculate each album from its media files
|
||||
for albumID, oldAlbum := range albumMap {
|
||||
mfs, hasTracks := filesByAlbum[albumID]
|
||||
if !hasTracks {
|
||||
// Album has no tracks anymore, skip (will be cleaned up by GC)
|
||||
log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Recalculate album from media files
|
||||
newAlbum := mfs.ToAlbum()
|
||||
|
||||
// Only update if something changed (avoid unnecessary writes)
|
||||
if !oldAlbum.Equals(newAlbum) {
|
||||
// Preserve original timestamps
|
||||
newAlbum.UpdatedAt = time.Now()
|
||||
newAlbum.CreatedAt = oldAlbum.CreatedAt
|
||||
|
||||
if err := albumRepo.Put(&newAlbum); err != nil {
|
||||
log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
|
||||
// Continue with other albums instead of failing entirely
|
||||
continue
|
||||
}
|
||||
log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAffectedAlbumIDs returns distinct album IDs from missing media files
|
||||
func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
|
||||
var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
|
||||
if len(ids) > 0 {
|
||||
filters = squirrel.And{
|
||||
squirrel.Eq{"missing": true},
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
}
|
||||
}
|
||||
|
||||
mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: filters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract unique album IDs
|
||||
albumIDMap := make(map[string]struct{}, len(mfs))
|
||||
for _, mf := range mfs {
|
||||
if mf.AlbumID != "" {
|
||||
albumIDMap[mf.AlbumID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
albumIDs := make([]string, 0, len(albumIDMap))
|
||||
for id := range albumIDMap {
|
||||
albumIDs = append(albumIDs, id)
|
||||
}
|
||||
|
||||
return albumIDs, nil
|
||||
}
|
||||
|
||||
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
||||
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
||||
// Refresh artist stats in background
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
bgCtx := request.AddValues(context.Background(), ctx)
|
||||
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
|
||||
}
|
||||
|
||||
// Refresh album stats in background if we have affected albums
|
||||
if len(affectedAlbumIDs) > 0 {
|
||||
if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
|
||||
} else {
|
||||
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait waits for all background goroutines to complete.
|
||||
// WARNING: This method is ONLY for testing. Never call this in production code.
|
||||
// Calling Wait() in production will block until ALL background operations complete
|
||||
// and may cause race conditions with new operations starting.
|
||||
func (s *maintenanceService) wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
364
core/maintenance_test.go
Normal file
364
core/maintenance_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var _ = Describe("Maintenance", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *extendedMediaFileRepo
|
||||
var service Maintenance
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
|
||||
|
||||
ds = createTestDataStore()
|
||||
mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
|
||||
service = NewMaintenance(ds)
|
||||
})
|
||||
|
||||
Describe("DeleteMissingFiles", func() {
|
||||
Context("with specific IDs", func() {
|
||||
It("deletes specific missing files and runs GC", func() {
|
||||
// Setup: mock missing files with album IDs
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
|
||||
Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
|
||||
})
|
||||
|
||||
It("triggers artist stats refresh and album refresh after deletion", func() {
|
||||
artistRepo := ds.MockedArtist.(*extendedArtistRepo)
|
||||
// Setup: mock missing files with albums
|
||||
albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Test Album", SongCount: 5},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
|
||||
{ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for background goroutines to complete
|
||||
service.(*maintenanceService).wait()
|
||||
|
||||
// RefreshStats should be called
|
||||
Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
|
||||
|
||||
// Album should be updated with new calculated values
|
||||
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
|
||||
})
|
||||
|
||||
It("returns error if deletion fails", func() {
|
||||
mfRepo.deleteMissingError = errors.New("delete failed")
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("delete failed"))
|
||||
})
|
||||
|
||||
It("continues even if album tracking fails", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
// Should not fail, just log warning
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns error if GC fails", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
// Set GC to return error
|
||||
ds.GCError = errors.New("gc failed")
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("gc failed"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("album ID extraction", func() {
|
||||
It("extracts unique album IDs from missing files", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips files without album IDs", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteAllMissingFiles", func() {
|
||||
It("deletes all missing files and runs GC", func() {
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album2", Missing: true},
|
||||
{ID: "mf3", AlbumID: "album3", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
|
||||
})
|
||||
|
||||
It("returns error if deletion fails", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles empty result gracefully", func() {
|
||||
mfRepo.SetData(model.MediaFiles{})
|
||||
|
||||
err := service.DeleteAllMissingFiles(ctx)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album refresh logic", func() {
|
||||
var albumRepo *extendedAlbumRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
|
||||
})
|
||||
|
||||
Context("when album has no tracks after deletion", func() {
|
||||
It("skips the album without updating it", func() {
|
||||
// Setup album with no remaining tracks
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Empty Album", SongCount: 1},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for background goroutines to complete
|
||||
service.(*maintenanceService).wait()
|
||||
|
||||
// Album should NOT be updated because it has no tracks left
|
||||
Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
|
||||
})
|
||||
})
|
||||
|
||||
Context("when Put fails for one album", func() {
|
||||
It("continues processing other albums", func() {
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Album 1"},
|
||||
{ID: "album2", Name: "Album 2"},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
{ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
|
||||
{ID: "mf3", AlbumID: "album2", Missing: true},
|
||||
{ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
|
||||
})
|
||||
|
||||
// Make Put fail on first call but succeed on subsequent calls
|
||||
albumRepo.putError = errors.New("put failed")
|
||||
albumRepo.failOnce = true
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
|
||||
|
||||
// Should not fail even if one album's Put fails
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for background goroutines to complete
|
||||
service.(*maintenanceService).wait()
|
||||
|
||||
// Put should have been called multiple times
|
||||
Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
|
||||
})
|
||||
})
|
||||
|
||||
Context("when media file loading fails", func() {
|
||||
It("logs warning but continues when tracking affected albums fails", func() {
|
||||
// Set up log capturing
|
||||
hook, cleanup := tests.LogHook()
|
||||
defer cleanup()
|
||||
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "album1", Name: "Album 1"},
|
||||
})
|
||||
mfRepo.SetData(model.MediaFiles{
|
||||
{ID: "mf1", AlbumID: "album1", Missing: true},
|
||||
})
|
||||
// Make GetAll fail when loading media files
|
||||
mfRepo.SetError(true)
|
||||
|
||||
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
|
||||
|
||||
// Deletion should succeed despite the tracking error
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
|
||||
|
||||
// Verify the warning was logged
|
||||
Expect(hook.LastEntry()).ToNot(BeNil())
|
||||
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
|
||||
Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Test helper to create a mock DataStore with controllable behavior
|
||||
func createTestDataStore() *tests.MockDataStore {
|
||||
ds := &tests.MockDataStore{}
|
||||
|
||||
// Create extended album repo with Put tracking
|
||||
albumRepo := &extendedAlbumRepo{
|
||||
MockAlbumRepo: tests.CreateMockAlbumRepo(),
|
||||
}
|
||||
ds.MockedAlbum = albumRepo
|
||||
|
||||
// Create extended artist repo with RefreshStats tracking
|
||||
artistRepo := &extendedArtistRepo{
|
||||
MockArtistRepo: tests.CreateMockArtistRepo(),
|
||||
}
|
||||
ds.MockedArtist = artistRepo
|
||||
|
||||
// Create extended media file repo with DeleteMissing support
|
||||
mfRepo := &extendedMediaFileRepo{
|
||||
MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
|
||||
}
|
||||
ds.MockedMediaFile = mfRepo
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
// Extension of MockMediaFileRepo to add DeleteMissing method
|
||||
type extendedMediaFileRepo struct {
|
||||
*tests.MockMediaFileRepo
|
||||
deleteMissingCalled bool
|
||||
deletedIDs []string
|
||||
deleteMissingError error
|
||||
}
|
||||
|
||||
func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
|
||||
m.deleteMissingCalled = true
|
||||
m.deletedIDs = ids
|
||||
if m.deleteMissingError != nil {
|
||||
return m.deleteMissingError
|
||||
}
|
||||
// Actually delete from the mock data
|
||||
for _, id := range ids {
|
||||
delete(m.Data, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extension of MockAlbumRepo to track Put calls
|
||||
type extendedAlbumRepo struct {
|
||||
*tests.MockAlbumRepo
|
||||
mu sync.RWMutex
|
||||
putCallCount int
|
||||
lastPutData *model.Album
|
||||
putError error
|
||||
failOnce bool
|
||||
}
|
||||
|
||||
func (m *extendedAlbumRepo) Put(album *model.Album) error {
|
||||
m.mu.Lock()
|
||||
m.putCallCount++
|
||||
m.lastPutData = album
|
||||
|
||||
// Handle failOnce behavior
|
||||
var err error
|
||||
if m.putError != nil {
|
||||
if m.failOnce {
|
||||
err = m.putError
|
||||
m.putError = nil // Clear error after first failure
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
err = m.putError
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.MockAlbumRepo.Put(album)
|
||||
}
|
||||
|
||||
func (m *extendedAlbumRepo) GetPutCallCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.putCallCount
|
||||
}
|
||||
|
||||
// Extension of MockArtistRepo to track RefreshStats calls
|
||||
type extendedArtistRepo struct {
|
||||
*tests.MockArtistRepo
|
||||
mu sync.RWMutex
|
||||
refreshStatsCalled bool
|
||||
refreshStatsError error
|
||||
}
|
||||
|
||||
func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
|
||||
m.mu.Lock()
|
||||
m.refreshStatsCalled = true
|
||||
err := m.refreshStatsError
|
||||
m.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.MockArtistRepo.RefreshStats(allArtists)
|
||||
}
|
||||
|
||||
func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.refreshStatsCalled
|
||||
}
|
||||
227
core/media_streamer.go
Normal file
227
core/media_streamer.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
transcoder ffmpeg.FFmpeg
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
filePath string
|
||||
format string
|
||||
bitRate int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw",
|
||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||
}()
|
||||
|
||||
format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate)
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
filePath := mf.AbsolutePath()
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReadCloser = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
filePath: filePath,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
offset: reqOffset,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
|
||||
s.ReadCloser = r
|
||||
s.Seeker = r.Seeker
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// TODO This function deserves some love (refactoring)
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
|
||||
format = "raw"
|
||||
if reqFormat == "raw" {
|
||||
return format, 0
|
||||
}
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return format, bitRate
|
||||
}
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
|
||||
cFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return format, bitRate
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return format, bitRate
|
||||
}
|
||||
|
||||
var (
|
||||
onceTranscodingCache sync.Once
|
||||
instanceTranscodingCache TranscodingCache
|
||||
)
|
||||
|
||||
func GetTranscodingCache() TranscodingCache {
|
||||
onceTranscodingCache.Do(func() {
|
||||
instanceTranscodingCache = NewTranscodingCache()
|
||||
})
|
||||
return instanceTranscodingCache
|
||||
}
|
||||
|
||||
func NewTranscodingCache() TranscodingCache {
|
||||
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
162
core/media_streamer_Internal_test.go
Normal file
162
core/media_streamer_Internal_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var ds model.DataStore
|
||||
ctx := log.NewContext(context.Background())
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
})
|
||||
|
||||
Context("selectTranscodingOptions", func() {
|
||||
mf := &model.MediaFile{}
|
||||
Context("player is not configured", func() {
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns raw if a transcoder does not exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if a transcoder exists", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 112
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns the requested format if requested BitRate is lower than original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 320
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(320))
|
||||
})
|
||||
Context("Downsampling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
mf.Suffix = "FLAC"
|
||||
mf.BitRate = 960
|
||||
})
|
||||
It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() {
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128)
|
||||
Expect(format).To(Equal("opus"))
|
||||
Expect(bitRate).To(Equal(128))
|
||||
})
|
||||
It("returns raw if maxBitrate is equal or greater than original", func() {
|
||||
// This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has format configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(96))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
})
|
||||
It("returns raw if selected bitrate and format is the same as original", func() {
|
||||
mf.Suffix = "mp3"
|
||||
mf.BitRate = 192
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192)
|
||||
Expect(format).To(Equal("raw"))
|
||||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
ctx = request.WithPlayer(ctx, p)
|
||||
})
|
||||
It("returns raw if raw is requested", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0)
|
||||
Expect(format).To(Equal("raw"))
|
||||
})
|
||||
It("returns configured format/bitrate as default", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0)
|
||||
Expect(format).To(Equal("mp3"))
|
||||
Expect(bitRate).To(Equal(160)) // Default Bit Rate
|
||||
})
|
||||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(160))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
74
core/media_streamer_test.go
Normal file
74
core/media_streamer_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var streamer core.MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := tests.NewMockFFmpeg("fake data")
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
|
||||
})
|
||||
testCache := core.NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.CacheFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is 0", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = io.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
330
core/metrics/insights.go
Normal file
330
core/metrics/insights.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics/insights"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type Insights interface {
|
||||
Run(ctx context.Context)
|
||||
LastRun(ctx context.Context) (timestamp time.Time, success bool)
|
||||
}
|
||||
|
||||
var (
|
||||
insightsID string
|
||||
)
|
||||
|
||||
type insightsCollector struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
lastRun atomic.Int64
|
||||
lastStatus atomic.Bool
|
||||
}
|
||||
|
||||
// PluginLoader defines an interface for loading plugins
|
||||
type PluginLoader interface {
|
||||
PluginList() map[string]schema.PluginManifest
|
||||
}
|
||||
|
||||
func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
||||
return singleton.GetInstance(func() *insightsCollector {
|
||||
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
||||
if err != nil {
|
||||
log.Trace("Could not get Insights ID from DB. Creating one", err)
|
||||
id = uuid.NewString()
|
||||
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
|
||||
if err != nil {
|
||||
log.Trace("Could not save Insights ID to DB", err)
|
||||
}
|
||||
}
|
||||
insightsID = id
|
||||
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *insightsCollector) Run(ctx context.Context) {
|
||||
for {
|
||||
// Refresh admin context on each iteration to handle cases where
|
||||
// admin user wasn't available on previous runs
|
||||
insightsCtx := auth.WithAdminUser(ctx, c.ds)
|
||||
u, _ := request.UserFrom(insightsCtx)
|
||||
if !u.IsAdmin {
|
||||
log.Trace(insightsCtx, "No admin user available, skipping insights collection")
|
||||
} else {
|
||||
c.sendInsights(insightsCtx)
|
||||
}
|
||||
select {
|
||||
case <-time.After(consts.InsightsUpdateInterval):
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
|
||||
t := c.lastRun.Load()
|
||||
return time.UnixMilli(t), c.lastStatus.Load()
|
||||
}
|
||||
|
||||
func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not check user count", err)
|
||||
return
|
||||
}
|
||||
if count == 0 {
|
||||
log.Trace(ctx, "No users found, skipping Insights data collection")
|
||||
return
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
data := c.collect(ctx)
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
body := bytes.NewReader(data)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not create Insights request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not send Insights data", err)
|
||||
return
|
||||
}
|
||||
log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
|
||||
string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
|
||||
c.lastRun.Store(time.Now().UnixMilli())
|
||||
c.lastStatus.Store(resp.StatusCode < 300)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func buildInfo() (map[string]string, string) {
|
||||
bInfo := map[string]string{}
|
||||
var version string
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, setting := range info.Settings {
|
||||
if setting.Value == "" {
|
||||
continue
|
||||
}
|
||||
bInfo[setting.Key] = setting.Value
|
||||
}
|
||||
version = info.GoVersion
|
||||
}
|
||||
return bInfo, version
|
||||
}
|
||||
|
||||
func getFSInfo(path string) *insights.FSInfo {
|
||||
var info insights.FSInfo
|
||||
|
||||
// Normalize the path
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
fsType, err := getFilesystemType(absPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
info.Type = fsType
|
||||
return &info
|
||||
}
|
||||
|
||||
var staticData = sync.OnceValue(func() insights.Data {
|
||||
// Basic info
|
||||
data := insights.Data{
|
||||
InsightsID: insightsID,
|
||||
Version: consts.Version,
|
||||
}
|
||||
|
||||
// Build info
|
||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
data.OS.Arch = runtime.GOARCH
|
||||
data.OS.NumCPU = runtime.NumCPU()
|
||||
data.OS.Version, data.OS.Distro = getOSVersion()
|
||||
|
||||
// FS info
|
||||
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
|
||||
data.FS.Data = getFSInfo(conf.Server.DataFolder)
|
||||
if conf.Server.CacheFolder != "" {
|
||||
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
|
||||
}
|
||||
if conf.Server.Backup.Path != "" {
|
||||
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
|
||||
}
|
||||
|
||||
// Config info
|
||||
data.Config.LogLevel = conf.Server.LogLevel
|
||||
data.Config.LogFileConfigured = conf.Server.LogFile != ""
|
||||
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
|
||||
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
|
||||
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
|
||||
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
|
||||
data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying
|
||||
data.Config.EnableDownloads = conf.Server.EnableDownloads
|
||||
data.Config.EnableSharing = conf.Server.EnableSharing
|
||||
data.Config.EnableStarRating = conf.Server.EnableStarRating
|
||||
data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != ""
|
||||
data.Config.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != ""
|
||||
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
|
||||
data.Config.EnableDeezer = conf.Server.Deezer.Enabled
|
||||
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
|
||||
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
|
||||
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
|
||||
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
|
||||
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
|
||||
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
|
||||
data.Config.SearchFullString = conf.Server.SearchFullString
|
||||
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
|
||||
data.Config.PreferSortTags = conf.Server.PreferSortTags
|
||||
data.Config.BackupSchedule = conf.Server.Backup.Schedule
|
||||
data.Config.BackupCount = conf.Server.Backup.Count
|
||||
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
|
||||
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
|
||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||
data := staticData()
|
||||
data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000
|
||||
|
||||
// Library info
|
||||
var err error
|
||||
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading tracks count", err)
|
||||
}
|
||||
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading albums count", err)
|
||||
}
|
||||
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading artists count", err)
|
||||
}
|
||||
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading playlists count", err)
|
||||
}
|
||||
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading shares count", err)
|
||||
}
|
||||
data.Library.Radios, err = c.ds.Radio(ctx).Count()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading radios count", err)
|
||||
}
|
||||
data.Library.Libraries, err = c.ds.Library(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading libraries count", err)
|
||||
}
|
||||
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading active users count", err)
|
||||
}
|
||||
|
||||
// Check for smart playlists
|
||||
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error checking for smart playlists", err)
|
||||
}
|
||||
|
||||
// Collect plugins if permitted and enabled
|
||||
if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled {
|
||||
data.Plugins = c.collectPlugins(ctx)
|
||||
}
|
||||
|
||||
// Collect active players if permitted
|
||||
if conf.Server.DevEnablePlayerInsights {
|
||||
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
|
||||
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
|
||||
})
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading active players count", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory info
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
data.Mem.Alloc = m.Alloc
|
||||
data.Mem.TotalAlloc = m.TotalAlloc
|
||||
data.Mem.Sys = m.Sys
|
||||
data.Mem.NumGC = m.NumGC
|
||||
|
||||
// Marshal to JSON
|
||||
resp, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not marshal Insights data", err)
|
||||
return nil
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// hasSmartPlaylists checks if there are any smart playlists (playlists with rules)
|
||||
func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) {
|
||||
count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}},
|
||||
})
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// collectPlugins collects information about installed plugins
|
||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||
plugins := make(map[string]insights.PluginInfo)
|
||||
for id, manifest := range c.pluginLoader.PluginList() {
|
||||
plugins[id] = insights.PluginInfo{
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
}
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
90
core/metrics/insights/data.go
Normal file
90
core/metrics/insights/data.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package insights
|
||||
|
||||
type Data struct {
|
||||
InsightsID string `json:"id"`
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Build struct {
|
||||
// build settings used by the Go compiler
|
||||
Settings map[string]string `json:"settings"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
} `json:"build"`
|
||||
OS struct {
|
||||
Type string `json:"type"`
|
||||
Distro string `json:"distro,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Containerized bool `json:"containerized"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"numCPU"`
|
||||
Package string `json:"package,omitempty"`
|
||||
} `json:"os"`
|
||||
Mem struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
TotalAlloc uint64 `json:"totalAlloc"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NumGC uint32 `json:"numGC"`
|
||||
} `json:"mem"`
|
||||
FS struct {
|
||||
Music *FSInfo `json:"music,omitempty"`
|
||||
Data *FSInfo `json:"data,omitempty"`
|
||||
Cache *FSInfo `json:"cache,omitempty"`
|
||||
Backup *FSInfo `json:"backup,omitempty"`
|
||||
} `json:"fs"`
|
||||
Library struct {
|
||||
Tracks int64 `json:"tracks"`
|
||||
Albums int64 `json:"albums"`
|
||||
Artists int64 `json:"artists"`
|
||||
Playlists int64 `json:"playlists"`
|
||||
Shares int64 `json:"shares"`
|
||||
Radios int64 `json:"radios"`
|
||||
Libraries int64 `json:"libraries"`
|
||||
ActiveUsers int64 `json:"activeUsers"`
|
||||
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
|
||||
} `json:"library"`
|
||||
Config struct {
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
|
||||
ImageCacheSize string `json:"imageCacheSize,omitempty"`
|
||||
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
|
||||
EnableDownloads bool `json:"enableDownloads,omitempty"`
|
||||
EnableSharing bool `json:"enableSharing,omitempty"`
|
||||
EnableStarRating bool `json:"enableStarRating,omitempty"`
|
||||
EnableLastFM bool `json:"enableLastFM,omitempty"`
|
||||
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
|
||||
EnableDeezer bool `json:"enableDeezer,omitempty"`
|
||||
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
|
||||
EnableSpotify bool `json:"enableSpotify,omitempty"`
|
||||
EnableJukebox bool `json:"enableJukebox,omitempty"`
|
||||
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
|
||||
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
|
||||
EnableNowPlaying bool `json:"enableNowPlaying,omitempty"`
|
||||
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
|
||||
SearchFullString bool `json:"searchFullString,omitempty"`
|
||||
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
|
||||
PreferSortTags bool `json:"preferSortTags,omitempty"`
|
||||
BackupSchedule string `json:"backupSchedule,omitempty"`
|
||||
BackupCount int `json:"backupCount,omitempty"`
|
||||
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
|
||||
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
|
||||
HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"`
|
||||
ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"`
|
||||
HasCustomPID bool `json:"hasCustomPID,omitempty"`
|
||||
HasCustomTags bool `json:"hasCustomTags,omitempty"`
|
||||
} `json:"config"`
|
||||
Plugins map[string]PluginInfo `json:"plugins,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type FSInfo struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
37
core/metrics/insights_darwin.go
Normal file
37
core/metrics/insights_darwin.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getOSVersion() (string, string) {
|
||||
cmd := exec.Command("sw_vers", "-productVersion")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), ""
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
var stat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &stat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert the filesystem type name from [16]int8 to string
|
||||
fsType := make([]byte, 0, 16)
|
||||
for _, c := range stat.Fstypename {
|
||||
if c == 0 {
|
||||
break
|
||||
}
|
||||
fsType = append(fsType, byte(c))
|
||||
}
|
||||
|
||||
return string(fsType), nil
|
||||
}
|
||||
9
core/metrics/insights_default.go
Normal file
9
core/metrics/insights_default.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !windows && !darwin
|
||||
|
||||
package metrics
|
||||
|
||||
import "errors"
|
||||
|
||||
func getOSVersion() (string, string) { return "", "" }
|
||||
|
||||
func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") }
|
||||
102
core/metrics/insights_linux.go
Normal file
102
core/metrics/insights_linux.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func getOSVersion() (string, string) {
|
||||
file, err := os.Open("/etc/os-release")
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
osRelease, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(osRelease), "\n")
|
||||
version := ""
|
||||
distro := ""
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "VERSION_ID=") {
|
||||
version = strings.ReplaceAll(strings.TrimPrefix(line, "VERSION_ID="), "\"", "")
|
||||
}
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
distro = strings.ReplaceAll(strings.TrimPrefix(line, "ID="), "\"", "")
|
||||
}
|
||||
}
|
||||
return version, distro
|
||||
}
|
||||
|
||||
// MountInfo represents an entry from /proc/self/mountinfo
|
||||
type MountInfo struct {
|
||||
MountPoint string
|
||||
FSType string
|
||||
}
|
||||
|
||||
var fsTypeMap = map[int64]string{
|
||||
0x5346414f: "afs",
|
||||
0x187: "autofs",
|
||||
0x61756673: "aufs",
|
||||
0x9123683E: "btrfs",
|
||||
0xc36400: "ceph",
|
||||
0xff534d42: "cifs",
|
||||
0x28cd3d45: "cramfs",
|
||||
0x64626720: "debugfs",
|
||||
0xf15f: "ecryptfs",
|
||||
0x2011bab0: "exfat",
|
||||
0x0000EF53: "ext2/ext3/ext4",
|
||||
0xf2f52010: "f2fs",
|
||||
0x6a656a63: "fakeowner", // FS inside a container
|
||||
0x65735546: "fuse",
|
||||
0x4244: "hfs",
|
||||
0x482b: "hfs+",
|
||||
0x9660: "iso9660",
|
||||
0x3153464a: "jfs",
|
||||
0x00006969: "nfs",
|
||||
0x5346544e: "ntfs", // NTFS_SB_MAGIC
|
||||
0x7366746e: "ntfs",
|
||||
0x794c7630: "overlayfs",
|
||||
0x9fa0: "proc",
|
||||
0x517b: "smb",
|
||||
0xfe534d42: "smb2",
|
||||
0x73717368: "squashfs",
|
||||
0x62656572: "sysfs",
|
||||
0x01021994: "tmpfs",
|
||||
0x01021997: "v9fs",
|
||||
0x786f4256: "vboxsf",
|
||||
0x4d44: "vfat",
|
||||
0xca451a4e: "virtiofs",
|
||||
0x58465342: "xfs",
|
||||
0x2FC12FC1: "zfs",
|
||||
0x7c7c6673: "prlfs", // Parallels Shared Folders
|
||||
|
||||
// Signed/unsigned conversion issues (negative hex values converted to uint32)
|
||||
-0x6edc97c2: "btrfs", // 0x9123683e
|
||||
-0x1acb2be: "smb2", // 0xfe534d42
|
||||
-0xacb2be: "cifs", // 0xff534d42
|
||||
-0xd0adff0: "f2fs", // 0xf2f52010
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
var fsStat syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &fsStat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fsType := fsStat.Type
|
||||
|
||||
fsName, exists := fsTypeMap[int64(fsType)] //nolint:unconvert
|
||||
if !exists {
|
||||
fsName = fmt.Sprintf("unknown(0x%x)", fsType)
|
||||
}
|
||||
|
||||
return fsName, nil
|
||||
}
|
||||
53
core/metrics/insights_windows.go
Normal file
53
core/metrics/insights_windows.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Ex: Microsoft Windows [Version 10.0.26100.1742]
|
||||
var winVerRegex = regexp.MustCompile(`Microsoft Windows \[.+\s([\d\.]+)\]`)
|
||||
|
||||
func getOSVersion() (version string, _ string) {
|
||||
cmd := exec.Command("cmd", "/c", "ver")
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
matches := winVerRegex.FindStringSubmatch(string(output))
|
||||
if len(matches) != 2 {
|
||||
return string(output), ""
|
||||
}
|
||||
return matches[1], ""
|
||||
}
|
||||
|
||||
func getFilesystemType(path string) (string, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var volumeName, filesystemName [windows.MAX_PATH + 1]uint16
|
||||
var serialNumber uint32
|
||||
var maxComponentLen, filesystemFlags uint32
|
||||
|
||||
err = windows.GetVolumeInformation(
|
||||
pathPtr,
|
||||
&volumeName[0],
|
||||
windows.MAX_PATH,
|
||||
&serialNumber,
|
||||
&maxComponentLen,
|
||||
&filesystemFlags,
|
||||
&filesystemName[0],
|
||||
windows.MAX_PATH)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return windows.UTF16ToString(filesystemName[:]), nil
|
||||
}
|
||||
240
core/metrics/prometheus.go
Normal file
240
core/metrics/prometheus.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"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/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type Metrics interface {
|
||||
WriteInitialMetrics(ctx context.Context)
|
||||
WriteAfterScanMetrics(ctx context.Context, success bool)
|
||||
RecordRequest(ctx context.Context, endpoint, method, client string, status int32, elapsed int64)
|
||||
RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64)
|
||||
GetHandler() http.Handler
|
||||
}
|
||||
|
||||
type metrics struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func GetPrometheusInstance(ds model.DataStore) Metrics {
|
||||
if !conf.Server.Prometheus.Enabled {
|
||||
return noopMetrics{}
|
||||
}
|
||||
|
||||
return singleton.GetInstance(func() *metrics {
|
||||
return &metrics{ds: ds}
|
||||
})
|
||||
}
|
||||
|
||||
func NewNoopInstance() Metrics {
|
||||
return noopMetrics{}
|
||||
}
|
||||
|
||||
func (m *metrics) WriteInitialMetrics(ctx context.Context) {
|
||||
getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1)
|
||||
processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal)
|
||||
}
|
||||
|
||||
func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) {
|
||||
processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal)
|
||||
|
||||
scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)}
|
||||
getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime()
|
||||
getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc()
|
||||
}
|
||||
|
||||
func (m *metrics) RecordRequest(_ context.Context, endpoint, method, client string, status int32, elapsed int64) {
|
||||
httpLabel := prometheus.Labels{
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"client": client,
|
||||
"status": strconv.FormatInt(int64(status), 10),
|
||||
}
|
||||
getPrometheusMetrics().httpRequestCounter.With(httpLabel).Inc()
|
||||
|
||||
httpLatencyLabel := prometheus.Labels{
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"client": client,
|
||||
}
|
||||
getPrometheusMetrics().httpRequestDuration.With(httpLatencyLabel).Observe(float64(elapsed))
|
||||
}
|
||||
|
||||
func (m *metrics) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) {
|
||||
pluginLabel := prometheus.Labels{
|
||||
"plugin": plugin,
|
||||
"method": method,
|
||||
"ok": strconv.FormatBool(ok),
|
||||
}
|
||||
getPrometheusMetrics().pluginRequestCounter.With(pluginLabel).Inc()
|
||||
|
||||
pluginLatencyLabel := prometheus.Labels{
|
||||
"plugin": plugin,
|
||||
"method": method,
|
||||
}
|
||||
getPrometheusMetrics().pluginRequestDuration.With(pluginLatencyLabel).Observe(float64(elapsed))
|
||||
}
|
||||
|
||||
func (m *metrics) GetHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
if conf.Server.Prometheus.Password != "" {
|
||||
r.Use(middleware.BasicAuth("metrics", map[string]string{
|
||||
consts.PrometheusAuthUser: conf.Server.Prometheus.Password,
|
||||
}))
|
||||
}
|
||||
|
||||
// Enable created at timestamp to handle zero counter on create.
|
||||
// This requires --enable-feature=created-timestamp-zero-ingestion to be passed in Prometheus
|
||||
r.Handle("/", promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{
|
||||
EnableOpenMetrics: true,
|
||||
EnableOpenMetricsTextCreatedSamples: true,
|
||||
}))
|
||||
return r
|
||||
}
|
||||
|
||||
type prometheusMetrics struct {
|
||||
dbTotal *prometheus.GaugeVec
|
||||
versionInfo *prometheus.GaugeVec
|
||||
lastMediaScan *prometheus.GaugeVec
|
||||
mediaScansCounter *prometheus.CounterVec
|
||||
httpRequestCounter *prometheus.CounterVec
|
||||
httpRequestDuration *prometheus.SummaryVec
|
||||
pluginRequestCounter *prometheus.CounterVec
|
||||
pluginRequestDuration *prometheus.SummaryVec
|
||||
}
|
||||
|
||||
// Prometheus' metrics requires initialization. But not more than once
|
||||
var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics {
|
||||
quartilesToEstimate := map[float64]float64{0.5: 0.05, 0.75: 0.025, 0.9: 0.01, 0.99: 0.001}
|
||||
|
||||
instance := &prometheusMetrics{
|
||||
dbTotal: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "db_model_totals",
|
||||
Help: "Total number of DB items per model",
|
||||
},
|
||||
[]string{"model"},
|
||||
),
|
||||
versionInfo: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "navidrome_info",
|
||||
Help: "Information about Navidrome version",
|
||||
},
|
||||
[]string{"version"},
|
||||
),
|
||||
lastMediaScan: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "media_scan_last",
|
||||
Help: "Last media scan timestamp by success",
|
||||
},
|
||||
[]string{"success"},
|
||||
),
|
||||
mediaScansCounter: prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "media_scans",
|
||||
Help: "Total success media scans by success",
|
||||
},
|
||||
[]string{"success"},
|
||||
),
|
||||
httpRequestCounter: prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_request_count",
|
||||
Help: "Request types by status",
|
||||
},
|
||||
[]string{"endpoint", "method", "client", "status"},
|
||||
),
|
||||
httpRequestDuration: prometheus.NewSummaryVec(
|
||||
prometheus.SummaryOpts{
|
||||
Name: "http_request_latency",
|
||||
Help: "Latency (in ms) of HTTP requests",
|
||||
Objectives: quartilesToEstimate,
|
||||
},
|
||||
[]string{"endpoint", "method", "client"},
|
||||
),
|
||||
pluginRequestCounter: prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "plugin_request_count",
|
||||
Help: "Plugin requests by method/status",
|
||||
},
|
||||
[]string{"plugin", "method", "ok"},
|
||||
),
|
||||
pluginRequestDuration: prometheus.NewSummaryVec(
|
||||
prometheus.SummaryOpts{
|
||||
Name: "plugin_request_latency",
|
||||
Help: "Latency (in ms) of plugin requests",
|
||||
Objectives: quartilesToEstimate,
|
||||
},
|
||||
[]string{"plugin", "method"},
|
||||
),
|
||||
}
|
||||
|
||||
prometheus.DefaultRegisterer.MustRegister(
|
||||
instance.dbTotal,
|
||||
instance.versionInfo,
|
||||
instance.lastMediaScan,
|
||||
instance.mediaScansCounter,
|
||||
instance.httpRequestCounter,
|
||||
instance.httpRequestDuration,
|
||||
instance.pluginRequestCounter,
|
||||
instance.pluginRequestDuration,
|
||||
)
|
||||
|
||||
return instance
|
||||
})
|
||||
|
||||
func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) {
|
||||
albumsCount, err := ds.Album(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Warn("album CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount))
|
||||
|
||||
artistCount, err := ds.Artist(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Warn("artist CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount))
|
||||
|
||||
songsCount, err := ds.MediaFile(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Warn("media CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount))
|
||||
|
||||
usersCount, err := ds.User(ctx).CountAll()
|
||||
if err != nil {
|
||||
log.Warn("user CountAll error", err)
|
||||
return
|
||||
}
|
||||
targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount))
|
||||
}
|
||||
|
||||
type noopMetrics struct {
|
||||
}
|
||||
|
||||
func (n noopMetrics) WriteInitialMetrics(context.Context) {}
|
||||
|
||||
func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {}
|
||||
|
||||
func (n noopMetrics) RecordRequest(context.Context, string, string, string, int32, int64) {}
|
||||
|
||||
func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {}
|
||||
|
||||
func (n noopMetrics) GetHandler() http.Handler { return nil }
|
||||
46
core/mock_library_service.go
Normal file
46
core/mock_library_service.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
)
|
||||
|
||||
// MockLibraryWrapper provides a simple wrapper around MockLibraryRepo
|
||||
// that implements the core.Library interface for testing
|
||||
type MockLibraryWrapper struct {
|
||||
*tests.MockLibraryRepo
|
||||
}
|
||||
|
||||
// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface
|
||||
type MockLibraryRestAdapter struct {
|
||||
*tests.MockLibraryRepo
|
||||
}
|
||||
|
||||
// NewMockLibraryService creates a new mock library service for testing
|
||||
func NewMockLibraryService() Library {
|
||||
repo := &tests.MockLibraryRepo{
|
||||
Data: make(map[int]model.Library),
|
||||
}
|
||||
// Set up default test data
|
||||
repo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
|
||||
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
|
||||
})
|
||||
return &MockLibraryWrapper{MockLibraryRepo: repo}
|
||||
}
|
||||
|
||||
func (m *MockLibraryWrapper) NewRepository(ctx context.Context) rest.Repository {
|
||||
return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo}
|
||||
}
|
||||
|
||||
// rest.Repository interface implementation
|
||||
|
||||
func (a *MockLibraryRestAdapter) Delete(id string) error {
|
||||
return a.DeleteByStringID(id)
|
||||
}
|
||||
|
||||
var _ Library = (*MockLibraryWrapper)(nil)
|
||||
var _ rest.Repository = (*MockLibraryRestAdapter)(nil)
|
||||
299
core/playback/device.go
Normal file
299
core/playback/device.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Track interface {
|
||||
IsPlaying() bool
|
||||
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
Pause()
|
||||
Unpause()
|
||||
Position() int
|
||||
SetPosition(offset int) error
|
||||
Close()
|
||||
String() string
|
||||
}
|
||||
|
||||
type playbackDevice struct {
|
||||
serviceCtx context.Context
|
||||
ParentPlaybackServer PlaybackServer
|
||||
Default bool
|
||||
User string
|
||||
Name string
|
||||
DeviceName string
|
||||
PlaybackQueue *Queue
|
||||
Gain float32
|
||||
PlaybackDone chan bool
|
||||
ActiveTrack Track
|
||||
startTrackSwitcher sync.Once
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
CurrentIndex int
|
||||
Playing bool
|
||||
Gain float32
|
||||
Position int
|
||||
}
|
||||
|
||||
const DefaultGain float32 = 1.0
|
||||
|
||||
func (pd *playbackDevice) getStatus() DeviceStatus {
|
||||
pos := 0
|
||||
if pd.ActiveTrack != nil {
|
||||
pos = pd.ActiveTrack.Position()
|
||||
}
|
||||
return DeviceStatus{
|
||||
CurrentIndex: pd.PlaybackQueue.Index,
|
||||
Playing: pd.isPlaying(),
|
||||
Gain: pd.Gain,
|
||||
Position: pos,
|
||||
}
|
||||
}
|
||||
|
||||
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
|
||||
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
|
||||
// Starts the trackSwitcher goroutine for the device.
|
||||
func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
|
||||
return &playbackDevice{
|
||||
serviceCtx: ctx,
|
||||
ParentPlaybackServer: playbackServer,
|
||||
User: "",
|
||||
Name: name,
|
||||
DeviceName: deviceName,
|
||||
Gain: DefaultGain,
|
||||
PlaybackQueue: NewQueue(),
|
||||
PlaybackDone: make(chan bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) String() string {
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Get action", "device", pd)
|
||||
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// Set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd)
|
||||
|
||||
_, err := pd.Clear(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting tracks", ids)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.Add(ctx, ids)
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Start action", "device", pd)
|
||||
|
||||
pd.startTrackSwitcher.Do(func() {
|
||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||
// Start one trackSwitcher goroutine with each device
|
||||
go func() {
|
||||
pd.trackSwitcherGoroutine()
|
||||
}()
|
||||
})
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
if pd.isPlaying() {
|
||||
log.Debug("trying to start an already playing track")
|
||||
} else {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
if !pd.PlaybackQueue.IsEmpty() {
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Stop action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
|
||||
|
||||
wasPlaying := pd.isPlaying()
|
||||
|
||||
if pd.ActiveTrack != nil && wasPlaying {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
|
||||
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if pd.ActiveTrack == nil {
|
||||
err := pd.switchActiveTrackByIndex(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
err := pd.ActiveTrack.SetPosition(offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting position", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
_, err = pd.Start(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error starting new track after skipping")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd)
|
||||
if len(ids) < 1 {
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
items := model.MediaFiles{}
|
||||
|
||||
for _, id := range ids {
|
||||
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
|
||||
if err != nil {
|
||||
return DeviceStatus{}, err
|
||||
}
|
||||
log.Debug(ctx, "Found mediafile: "+mf.Path)
|
||||
items = append(items, *mf)
|
||||
}
|
||||
pd.PlaybackQueue.Add(items)
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Clear action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
pd.PlaybackQueue.Clear()
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
|
||||
// pausing if attempting to remove running track
|
||||
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
|
||||
_, err := pd.Stop(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error stopping running track")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
if index > -1 && index < pd.PlaybackQueue.Size() {
|
||||
pd.PlaybackQueue.Remove(index)
|
||||
} else {
|
||||
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Shuffle action", "device", pd)
|
||||
if pd.PlaybackQueue.Size() > 1 {
|
||||
pd.PlaybackQueue.Shuffle()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.SetVolume(gain)
|
||||
}
|
||||
pd.Gain = gain
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) isPlaying() bool {
|
||||
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
||||
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
||||
for {
|
||||
select {
|
||||
case <-pd.PlaybackDone:
|
||||
log.Debug("Track switching detected")
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if !pd.PlaybackQueue.IsAtLastElement() {
|
||||
pd.PlaybackQueue.IncreaseIndex()
|
||||
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
log.Error("Error switching track", err)
|
||||
}
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
log.Debug("There is no song left in the playlist. Finish.")
|
||||
}
|
||||
case <-pd.serviceCtx.Done():
|
||||
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
|
||||
pd.PlaybackQueue.SetIndex(index)
|
||||
currentTrack := pd.PlaybackQueue.Current()
|
||||
if currentTrack == nil {
|
||||
return errors.New("could not get current track")
|
||||
}
|
||||
|
||||
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.ActiveTrack = track
|
||||
pd.ActiveTrack.SetVolume(pd.Gain)
|
||||
return nil
|
||||
}
|
||||
131
core/playback/mpv/mpv.go
Normal file
131
core/playback/mpv/mpv.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func start(ctx context.Context, args []string) (Executor, error) {
|
||||
if len(args) == 0 {
|
||||
return Executor{}, fmt.Errorf("no command arguments provided")
|
||||
}
|
||||
log.Debug("Executing mpv command", "cmd", args)
|
||||
j := Executor{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start(ctx)
|
||||
if err != nil {
|
||||
return Executor{}, err
|
||||
}
|
||||
go j.wait()
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func (j *Executor) Cancel() error {
|
||||
if j.cmd != nil {
|
||||
return j.cmd.Cancel()
|
||||
}
|
||||
return fmt.Errorf("there is non command to cancel")
|
||||
}
|
||||
|
||||
type Executor struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (j *Executor) start(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting cmd: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *Executor) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||
// Parse the template structure using shell parsing to handle quoted arguments
|
||||
templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
|
||||
if err != nil {
|
||||
log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace placeholders in each parsed argument to preserve spaces in substituted values
|
||||
for i, arg := range templateArgs {
|
||||
arg = strings.ReplaceAll(arg, "%d", deviceName)
|
||||
arg = strings.ReplaceAll(arg, "%f", filename)
|
||||
arg = strings.ReplaceAll(arg, "%s", socketName)
|
||||
templateArgs[i] = arg
|
||||
}
|
||||
|
||||
// Replace mpv executable references with the configured path
|
||||
if len(templateArgs) > 0 {
|
||||
cmdPath, err := mpvCommand()
|
||||
if err == nil {
|
||||
if templateArgs[0] == "mpv" || templateArgs[0] == "mpv.exe" {
|
||||
templateArgs[0] = cmdPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return templateArgs
|
||||
}
|
||||
|
||||
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
|
||||
func mpvCommand() (string, error) {
|
||||
mpvOnce.Do(func() {
|
||||
if conf.Server.MPVPath != "" {
|
||||
mpvPath = conf.Server.MPVPath
|
||||
mpvPath, mpvErr = exec.LookPath(mpvPath)
|
||||
} else {
|
||||
mpvPath, mpvErr = exec.LookPath("mpv")
|
||||
if errors.Is(mpvErr, exec.ErrDot) {
|
||||
log.Trace("mpv found in current folder '.'")
|
||||
mpvPath, mpvErr = exec.LookPath("./mpv")
|
||||
}
|
||||
}
|
||||
if mpvErr == nil {
|
||||
log.Info("Found mpv", "path", mpvPath)
|
||||
return
|
||||
}
|
||||
})
|
||||
return mpvPath, mpvErr
|
||||
}
|
||||
|
||||
var (
|
||||
mpvOnce sync.Once
|
||||
mpvPath string
|
||||
mpvErr error
|
||||
)
|
||||
17
core/playback/mpv/mpv_suite_test.go
Normal file
17
core/playback/mpv/mpv_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMPV(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "MPV Suite")
|
||||
}
|
||||
390
core/playback/mpv/mpv_test.go
Normal file
390
core/playback/mpv/mpv_test.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MPV", func() {
|
||||
var (
|
||||
testScript string
|
||||
tempDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
// Reset MPV cache
|
||||
mpvOnce = sync.Once{}
|
||||
mpvPath = ""
|
||||
mpvErr = nil
|
||||
|
||||
// Create temporary directory for test files
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "mpv_test_*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
// Create mock MPV script that outputs arguments to stdout
|
||||
testScript = createMockMPVScript(tempDir)
|
||||
|
||||
// Configure test MPV path
|
||||
conf.Server.MPVPath = testScript
|
||||
})
|
||||
|
||||
Describe("createMPVCommand", func() {
|
||||
Context("with default template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("creates correct command with simple paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles paths with spaces", func() {
|
||||
args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/My Album/01 - Song.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles complex device names", func() {
|
||||
deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1"
|
||||
args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=" + deviceName,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with snapcast template (issue #3619)", func() {
|
||||
BeforeEach(func() {
|
||||
// This is the template that fails with naive space splitting
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("creates correct command for snapcast integration", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
"--audio-samplerate=48000",
|
||||
"--audio-format=s16",
|
||||
"--ao=pcm",
|
||||
"--ao-pcm-file=/audio/snapcast_fifo",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with wrapper script template", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case that would break with naive splitting due to quoted arguments
|
||||
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo`
|
||||
})
|
||||
|
||||
It("handles wrapper script paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
"/tmp/mpv.sh",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with extra spaces in template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("handles extra spaces correctly", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
})
|
||||
Context("with paths containing spaces in template arguments", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with spaces in the path arguments themselves
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
|
||||
})
|
||||
|
||||
It("handles spaces in quoted template argument paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
// This test reveals the limitation of strings.Fields() - it will split on all spaces
|
||||
// Expected behavior would be to keep the path as one argument
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns nil when shell parsing fails", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with empty template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = ""
|
||||
})
|
||||
|
||||
It("returns empty slice for empty template", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("start", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("executes MPV command and captures arguments correctly", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/test.mp3"
|
||||
socketName := "/tmp/test_socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read all the output from stdout (this will block until the process finishes or is canceled)
|
||||
output, err := io.ReadAll(executor)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
Expect(lines).To(HaveLen(6))
|
||||
Expect(lines[0]).To(Equal(testScript))
|
||||
Expect(lines[1]).To(Equal("--audio-device=auto"))
|
||||
Expect(lines[2]).To(Equal("--no-audio-display"))
|
||||
Expect(lines[3]).To(Equal("--pause"))
|
||||
Expect(lines[4]).To(Equal("/music/test.mp3"))
|
||||
Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket"))
|
||||
})
|
||||
|
||||
It("handles file paths with spaces", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/My Album/01 - My Song.mp3"
|
||||
socketName := "/tmp/test socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read all the output from stdout (this will block until the process finishes or is canceled)
|
||||
output, err := io.ReadAll(executor)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket"))
|
||||
})
|
||||
|
||||
Context("with complex snapcast configuration", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("passes all snapcast arguments correctly", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/album/track.flac"
|
||||
socketName := "/tmp/mpv-ctrl-test.socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read all the output from stdout (this will block until the process finishes or is canceled)
|
||||
output, err := io.ReadAll(executor)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
|
||||
// Verify all expected arguments are present
|
||||
Expect(lines).To(ContainElement("--no-audio-display"))
|
||||
Expect(lines).To(ContainElement("--pause"))
|
||||
Expect(lines).To(ContainElement("/music/album/track.flac"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket"))
|
||||
Expect(lines).To(ContainElement("--audio-channels=stereo"))
|
||||
Expect(lines).To(ContainElement("--audio-samplerate=48000"))
|
||||
Expect(lines).To(ContainElement("--audio-format=s16"))
|
||||
Expect(lines).To(ContainElement("--ao=pcm"))
|
||||
Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with nil args", func() {
|
||||
It("returns error when args is nil", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := start(ctx, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no command arguments provided"))
|
||||
})
|
||||
|
||||
It("returns error when args is empty", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := start(ctx, []string{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no command arguments provided"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("mpvCommand", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset the mpv command cache
|
||||
mpvOnce = sync.Once{}
|
||||
mpvPath = ""
|
||||
mpvErr = nil
|
||||
})
|
||||
|
||||
It("finds the configured MPV path", func() {
|
||||
conf.Server.MPVPath = testScript
|
||||
path, err := mpvCommand()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(testScript))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NewTrack integration", func() {
|
||||
var testMediaFile model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MPVPath = testScript
|
||||
|
||||
// Create a test media file
|
||||
testMediaFile = model.MediaFile{
|
||||
ID: "test-id",
|
||||
Path: "/music/test.mp3",
|
||||
}
|
||||
})
|
||||
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns error when createMPVCommand fails", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
playbackDone := make(chan bool, 1)
|
||||
_, err := NewTrack(ctx, playbackDone, "auto", testMediaFile)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no mpv command arguments provided"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createMockMPVScript creates a mock script that outputs arguments to stdout
|
||||
func createMockMPVScript(tempDir string) string {
|
||||
var scriptContent string
|
||||
var scriptExt string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
scriptExt = ".bat"
|
||||
scriptContent = `@echo off
|
||||
echo %0
|
||||
:loop
|
||||
if "%~1"=="" goto end
|
||||
echo %~1
|
||||
shift
|
||||
goto loop
|
||||
:end
|
||||
`
|
||||
} else {
|
||||
scriptExt = ".sh"
|
||||
scriptContent = `#!/bin/sh
|
||||
echo "$0"
|
||||
for arg in "$@"; do
|
||||
echo "$arg"
|
||||
done
|
||||
`
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(tempDir, "mock_mpv"+scriptExt)
|
||||
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755) // nolint:gosec
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create mock script: %v", err))
|
||||
}
|
||||
|
||||
return scriptPath
|
||||
}
|
||||
22
core/playback/mpv/sockets.go
Normal file
22
core/playback/mpv/sockets.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
func socketName(prefix, suffix string) string {
|
||||
return utils.TempFileName(prefix, suffix)
|
||||
}
|
||||
|
||||
func removeSocket(socketName string) {
|
||||
log.Debug("Removing socketfile", "socketfile", socketName)
|
||||
err := os.Remove(socketName)
|
||||
if err != nil {
|
||||
log.Error("Error cleaning up socketfile", "socketfile", socketName, err)
|
||||
}
|
||||
}
|
||||
19
core/playback/mpv/sockets_win.go
Normal file
19
core/playback/mpv/sockets_win.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
func socketName(prefix, suffix string) string {
|
||||
// Windows needs to use a named pipe for the socket
|
||||
// see https://mpv.io/manual/master#using-mpv-from-other-programs-or-scripts
|
||||
return filepath.Join(`\\.\pipe\mpvsocket`, prefix+id.NewRandom()+suffix)
|
||||
}
|
||||
|
||||
func removeSocket(string) {
|
||||
// Windows automatically handles cleaning up named pipe
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user