mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
* refactor(agents): drop single Artist fields from Song, keep only Artists Song now represents credited artists solely via the Artists slice; the single Artist/ArtistMBID fields and the ArtistList passthrough are removed. Equals continues to hash the whole value. * refactor(lastfm,listenbrainz): build Song.Artists in built-in agents Last.fm and ListenBrainz now populate the Artists slice directly. For ListenBrainz top songs, all credited artist MBIDs are mapped (the combined display name on the first credit plus MBID-only collaborators) instead of keeping only the first MBID, feeding the matcher's per-MBID specificity. * refactor(external): set Song.Artists in top-songs enrichment getMatchingTopSongs now seeds an Artists entry from the known artist when a song carries none, replacing the single Artist/ArtistMBID writes. * refactor(plugins): fold single-artist SongRef into Song.Artists SongRef keeps its single Artist/ArtistMBID fields as part of the plugin wire contract; songRefToAgentSong now folds them into a one-element Artists list when a plugin sends no artists array. * refactor(matcher): read Song.Artists directly groupQueries consumes s.Artists now that ArtistList is gone; test inputs build the Artists slice. * fix(matcher): keep MBID-only artists as identity signals Review follow-up: an artist credited only by MBID (empty ID and name) was dropped before resolution in four places, defeating the multi-MBID matching path this PR adds. - matcher.groupQueries: treat a non-empty MBID as a usable artist signal - external.getMatchingTopSongs: backfill the primary credit's name/MBID when the agent left them empty - plugins.songRefToAgentSong: fold a single-artist SongRef when only ArtistMBID is set (not just when Artist name is set) - listenbrainz.topSongArtists: return nil instead of an empty-name placeholder when neither name nor MBIDs are present * fix(external): only backfill top-song artist onto an unnamed credit Review follow-up (codex P2): the previous backfill stamped the queried artist's MBID onto Artists[0] whenever it was empty, even when that credit already named a different (e.g. featured) artist — producing a mismatched name+MBID pair that could mis-rank matches. Now only an unnamed first credit is filled (it is, by construction, the queried artist); an already-named credit is left untouched. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
361 lines
14 KiB
Go
361 lines
14 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/plugins/capabilities"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("MetadataAgent", Ordered, func() {
|
|
var agent agents.Interface
|
|
|
|
BeforeAll(func() {
|
|
// Load the agent via shared manager
|
|
var ok bool
|
|
agent, ok = testManager.LoadMediaAgent("test-metadata-agent")
|
|
Expect(ok).To(BeTrue())
|
|
})
|
|
|
|
Describe("AgentName", func() {
|
|
It("returns the plugin name", func() {
|
|
Expect(agent.AgentName()).To(Equal("test-metadata-agent"))
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistMBID", func() {
|
|
It("returns the MBID from the plugin", func() {
|
|
retriever := agent.(agents.ArtistMBIDRetriever)
|
|
mbid, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "The Beatles")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mbid).To(Equal("test-mbid-The Beatles"))
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistURL", func() {
|
|
It("returns the URL from the plugin", func() {
|
|
retriever := agent.(agents.ArtistURLRetriever)
|
|
url, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(url).To(Equal("https://test.example.com/artist/The Beatles"))
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistBiography", func() {
|
|
It("returns the biography from the plugin", func() {
|
|
retriever := agent.(agents.ArtistBiographyRetriever)
|
|
bio, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(bio).To(Equal("Biography for The Beatles"))
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistImages", func() {
|
|
It("returns images from the plugin", func() {
|
|
retriever := agent.(agents.ArtistImageRetriever)
|
|
images, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(images).To(HaveLen(2))
|
|
Expect(images[0].URL).To(Equal("https://test.example.com/images/The Beatles/large.jpg"))
|
|
Expect(images[0].Size).To(Equal(500))
|
|
Expect(images[1].URL).To(Equal("https://test.example.com/images/The Beatles/small.jpg"))
|
|
Expect(images[1].Size).To(Equal(100))
|
|
})
|
|
})
|
|
|
|
Describe("GetSimilarArtists", func() {
|
|
It("returns similar artists from the plugin", func() {
|
|
retriever := agent.(agents.ArtistSimilarRetriever)
|
|
artists, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(artists).To(HaveLen(3))
|
|
Expect(artists[0].Name).To(Equal("The Beatles Similar A"))
|
|
Expect(artists[1].Name).To(Equal("The Beatles Similar B"))
|
|
Expect(artists[2].Name).To(Equal("The Beatles Similar C"))
|
|
})
|
|
})
|
|
|
|
Describe("GetArtistTopSongs", func() {
|
|
It("returns top songs from the plugin", func() {
|
|
retriever := agent.(agents.ArtistTopSongsRetriever)
|
|
songs, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3))
|
|
Expect(songs[0].Name).To(Equal("The Beatles Song 1"))
|
|
Expect(songs[1].Name).To(Equal("The Beatles Song 2"))
|
|
Expect(songs[2].Name).To(Equal("The Beatles Song 3"))
|
|
})
|
|
})
|
|
|
|
Describe("GetAlbumInfo", func() {
|
|
It("returns album info from the plugin", func() {
|
|
retriever := agent.(agents.AlbumInfoRetriever)
|
|
info, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Abbey Road", "The Beatles", "album-mbid")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(info.Name).To(Equal("Abbey Road"))
|
|
Expect(info.MBID).To(Equal("test-album-mbid-Abbey Road"))
|
|
Expect(info.Description).To(Equal("Description for Abbey Road by The Beatles"))
|
|
Expect(info.URL).To(Equal("https://test.example.com/album/Abbey Road"))
|
|
})
|
|
})
|
|
|
|
Describe("GetAlbumImages", func() {
|
|
It("returns album images from the plugin", func() {
|
|
retriever := agent.(agents.AlbumImageRetriever)
|
|
images, err := retriever.GetAlbumImages(GinkgoT().Context(), "Abbey Road", "The Beatles", "album-mbid")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(images).To(HaveLen(1))
|
|
Expect(images[0].URL).To(Equal("https://test.example.com/albums/Abbey Road/cover.jpg"))
|
|
Expect(images[0].Size).To(Equal(500))
|
|
})
|
|
})
|
|
|
|
Describe("GetSimilarSongsByTrack", func() {
|
|
It("returns similar songs from the plugin", func() {
|
|
retriever := agent.(agents.SimilarSongsByTrackRetriever)
|
|
songs, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Yesterday", "The Beatles", "some-mbid", 3)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3))
|
|
Expect(songs[0].Name).To(Equal("Similar to Yesterday #1"))
|
|
Expect(songs[0].Artists).To(HaveLen(1))
|
|
Expect(songs[0].Artists[0].Name).To(Equal("The Beatles"))
|
|
})
|
|
})
|
|
|
|
Describe("GetSimilarSongsByAlbum", func() {
|
|
It("returns similar songs from the plugin", func() {
|
|
retriever := agent.(agents.SimilarSongsByAlbumRetriever)
|
|
songs, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Abbey Road", "The Beatles", "album-mbid", 3)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3))
|
|
Expect(songs[0].Album).To(Equal("Abbey Road"))
|
|
})
|
|
})
|
|
|
|
Describe("GetSimilarSongsByArtist", func() {
|
|
It("returns similar songs from the plugin", func() {
|
|
retriever := agent.(agents.SimilarSongsByArtistRetriever)
|
|
songs, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3))
|
|
Expect(songs[0].Name).To(ContainSubstring("The Beatles Style Song"))
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("MetadataAgent error handling", Ordered, func() {
|
|
// Tests error paths when plugin is configured to return errors
|
|
var (
|
|
errorManager *Manager
|
|
errorAgent agents.Interface
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
// Create manager with error injection config
|
|
errorManager, _ = createTestManager(map[string]map[string]string{
|
|
"test-metadata-agent": {
|
|
"error": "simulated plugin error",
|
|
},
|
|
})
|
|
|
|
// Load the agent
|
|
var ok bool
|
|
errorAgent, ok = errorManager.LoadMediaAgent("test-metadata-agent")
|
|
Expect(ok).To(BeTrue())
|
|
})
|
|
|
|
It("returns error from GetArtistMBID", func() {
|
|
retriever := errorAgent.(agents.ArtistMBIDRetriever)
|
|
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetArtistURL", func() {
|
|
retriever := errorAgent.(agents.ArtistURLRetriever)
|
|
_, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetArtistBiography", func() {
|
|
retriever := errorAgent.(agents.ArtistBiographyRetriever)
|
|
_, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetArtistImages", func() {
|
|
retriever := errorAgent.(agents.ArtistImageRetriever)
|
|
_, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetSimilarArtists", func() {
|
|
retriever := errorAgent.(agents.ArtistSimilarRetriever)
|
|
_, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetArtistTopSongs", func() {
|
|
retriever := errorAgent.(agents.ArtistTopSongsRetriever)
|
|
_, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetAlbumInfo", func() {
|
|
retriever := errorAgent.(agents.AlbumInfoRetriever)
|
|
_, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetAlbumImages", func() {
|
|
retriever := errorAgent.(agents.AlbumImageRetriever)
|
|
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetSimilarSongsByTrack", func() {
|
|
retriever := errorAgent.(agents.SimilarSongsByTrackRetriever)
|
|
_, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetSimilarSongsByAlbum", func() {
|
|
retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever)
|
|
_, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
|
|
It("returns error from GetSimilarSongsByArtist", func() {
|
|
retriever := errorAgent.(agents.SimilarSongsByArtistRetriever)
|
|
_, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
|
|
// Tests the "not implemented" code path when a plugin only implements some methods
|
|
var (
|
|
partialManager *Manager
|
|
partialAgent agents.Interface
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
// Create manager with the partial metadata agent plugin
|
|
partialManager, _ = createTestManagerWithPlugins(nil, "partial-metadata-agent"+PackageExtension)
|
|
|
|
// Load the agent
|
|
var ok bool
|
|
partialAgent, ok = partialManager.LoadMediaAgent("partial-metadata-agent")
|
|
Expect(ok).To(BeTrue())
|
|
})
|
|
|
|
It("returns data from implemented method (GetArtistBiography)", func() {
|
|
retriever := partialAgent.(agents.ArtistBiographyRetriever)
|
|
bio, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(bio).To(Equal("Partial agent biography for Test Artist"))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetArtistMBID)", func() {
|
|
retriever := partialAgent.(agents.ArtistMBIDRetriever)
|
|
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetArtistURL)", func() {
|
|
retriever := partialAgent.(agents.ArtistURLRetriever)
|
|
_, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetArtistImages)", func() {
|
|
retriever := partialAgent.(agents.ArtistImageRetriever)
|
|
_, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetSimilarArtists)", func() {
|
|
retriever := partialAgent.(agents.ArtistSimilarRetriever)
|
|
_, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetArtistTopSongs)", func() {
|
|
retriever := partialAgent.(agents.ArtistTopSongsRetriever)
|
|
_, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetAlbumInfo)", func() {
|
|
retriever := partialAgent.(agents.AlbumInfoRetriever)
|
|
_, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetAlbumImages)", func() {
|
|
retriever := partialAgent.(agents.AlbumImageRetriever)
|
|
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() {
|
|
retriever := partialAgent.(agents.SimilarSongsByTrackRetriever)
|
|
_, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() {
|
|
retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever)
|
|
_, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
|
|
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() {
|
|
retriever := partialAgent.(agents.SimilarSongsByArtistRetriever)
|
|
_, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
|
|
Expect(err).To(MatchError(errNotImplemented))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("songRefToAgentSong multi-artist", func() {
|
|
It("maps ArtistRef to agents.Artist", func() {
|
|
ref := capabilities.SongRef{Name: "Collab", Artist: "Drake", Artists: []capabilities.ArtistRef{
|
|
{ID: "id-drake", Name: "Drake", MBID: "m-drake"},
|
|
{Name: "Future", MBID: "m-future"},
|
|
}}
|
|
got := songRefToAgentSong(ref)
|
|
Expect(got.Artists).To(Equal([]agents.Artist{
|
|
{ID: "id-drake", Name: "Drake", MBID: "m-drake"},
|
|
{Name: "Future", MBID: "m-future"},
|
|
}))
|
|
})
|
|
It("folds the single Artist/ArtistMBID into a one-element Artists when no Artists provided", func() {
|
|
ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"}
|
|
got := songRefToAgentSong(ref)
|
|
Expect(got.Artists).To(Equal([]agents.Artist{{Name: "Drake", MBID: "m-drake"}}))
|
|
})
|
|
It("folds an MBID-only single artist (empty name) so the MBID is not dropped", func() {
|
|
ref := capabilities.SongRef{Name: "Solo", ArtistMBID: "m-drake"}
|
|
got := songRefToAgentSong(ref)
|
|
Expect(got.Artists).To(Equal([]agents.Artist{{MBID: "m-drake"}}))
|
|
})
|
|
It("leaves Artists nil when neither Artists nor the single Artist/ArtistMBID are provided", func() {
|
|
got := songRefToAgentSong(capabilities.SongRef{Name: "Anon"})
|
|
Expect(got.Artists).To(BeNil())
|
|
})
|
|
})
|