feat(plugins): add similar songs retrieval functions and improve duration consistency (#4933)
* feat: add duration filtering for similar songs matching Signed-off-by: Deluan <deluan@navidrome.org> * test: refactor expectations for similar songs in provider matching tests Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): add functions to retrieve similar songs by track, album, and artist Signed-off-by: Deluan <deluan@navidrome.org> * fix(plugins): support uint32 in ndpgen Signed-off-by: Deluan <deluan@navidrome.org> * fix(plugins): update duration field to use seconds as float instead of milliseconds as uint32 Signed-off-by: Deluan <deluan@navidrome.org> * fix: add helper functions for Rust's skip_serializing_if with numeric types Signed-off-by: Deluan <deluan@navidrome.org> * feat(provider): enhance track matching logic to fallback to title match when duration-filtered tracks fail --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -137,6 +137,7 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st
|
|||||||
return agents.Song{
|
return agents.Song{
|
||||||
Name: r.Title,
|
Name: r.Title,
|
||||||
Album: r.Album.Title,
|
Album: r.Album.Title,
|
||||||
|
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return res, nil
|
return res, nil
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type Song struct {
|
|||||||
ArtistMBID string
|
ArtistMBID string
|
||||||
Album string
|
Album string
|
||||||
AlbumMBID string
|
AlbumMBID string
|
||||||
|
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
Vendored
+46
@@ -3,6 +3,7 @@ package external
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
@@ -12,6 +13,11 @@ import (
|
|||||||
"github.com/xrash/smetrics"
|
"github.com/xrash/smetrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// durationToleranceSec is the maximum allowed difference in seconds when
|
||||||
|
// matching tracks by duration. A tolerance of 3 seconds accounts for minor
|
||||||
|
// encoding differences between sources.
|
||||||
|
const durationToleranceSec = 3
|
||||||
|
|
||||||
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||||
// matching algorithm that prioritizes accuracy over recall.
|
// matching algorithm that prioritizes accuracy over recall.
|
||||||
//
|
//
|
||||||
@@ -175,6 +181,7 @@ type songQuery struct {
|
|||||||
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
||||||
album string // Sanitized album name (optional, for specificity scoring)
|
album string // Sanitized album name (optional, for specificity scoring)
|
||||||
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
|
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
|
||||||
|
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
||||||
@@ -282,12 +289,50 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
|||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// durationMatches checks if a track's duration is within tolerance of the target duration.
|
||||||
|
// Returns true if durationMs is 0 (unknown) or if the difference is within durationToleranceSec.
|
||||||
|
func durationMatches(durationMs uint32, mediaFileDurationSec float32) bool {
|
||||||
|
if durationMs <= 0 {
|
||||||
|
return true // Unknown duration matches anything
|
||||||
|
}
|
||||||
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
|
||||||
|
return diff <= durationToleranceSec
|
||||||
|
}
|
||||||
|
|
||||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||||
|
// When duration is known (durationMs > 0), it acts as a top-priority filter:
|
||||||
|
// - First, only tracks with matching duration (±3 seconds) are considered
|
||||||
|
// - If no title match is found among duration-filtered tracks, falls back to matching all tracks
|
||||||
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
||||||
// 1. Highest title similarity
|
// 1. Highest title similarity
|
||||||
// 2. Highest specificity level
|
// 2. Highest specificity level
|
||||||
// 3. Highest album similarity (as final tiebreaker)
|
// 3. Highest album similarity (as final tiebreaker)
|
||||||
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||||
|
// If duration is known, try to find matches among duration-filtered tracks first
|
||||||
|
if q.durationMs > 0 {
|
||||||
|
var durationFiltered model.MediaFiles
|
||||||
|
for _, mf := range tracks {
|
||||||
|
if durationMatches(q.durationMs, mf.Duration) {
|
||||||
|
durationFiltered = append(durationFiltered, mf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we have duration-filtered candidates, try matching those first
|
||||||
|
if len(durationFiltered) > 0 {
|
||||||
|
if mf, found := findBestMatchInTracks(q, durationFiltered, threshold); found {
|
||||||
|
return mf, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall through to try all tracks if no duration-filtered match found
|
||||||
|
}
|
||||||
|
|
||||||
|
return findBestMatchInTracks(q, tracks, threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBestMatchInTracks performs the core matching logic on a set of tracks.
|
||||||
|
// It finds the track with the best combined score based on title similarity,
|
||||||
|
// specificity level, and album similarity.
|
||||||
|
func findBestMatchInTracks(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||||
var bestMatch model.MediaFile
|
var bestMatch model.MediaFile
|
||||||
bestScore := matchScore{titleSimilarity: -1}
|
bestScore := matchScore{titleSimilarity: -1}
|
||||||
found := false
|
found := false
|
||||||
@@ -338,6 +383,7 @@ func (e *provider) buildTitleQueries(songs []agents.Song, idMatches, mbidMatches
|
|||||||
artistMBID: s.ArtistMBID,
|
artistMBID: s.ArtistMBID,
|
||||||
album: str.SanitizeFieldForSorting(s.Album),
|
album: str.SanitizeFieldForSorting(s.Album),
|
||||||
albumMBID: s.AlbumMBID,
|
albumMBID: s.AlbumMBID,
|
||||||
|
durationMs: s.Duration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return queries
|
return queries
|
||||||
|
|||||||
+195
-28
@@ -41,6 +41,26 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
provider = NewProvider(ds, agentsCombined)
|
provider = NewProvider(ds, agentsCombined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
|
||||||
|
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
|
||||||
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
|
Return(returnedSongs, nil).Once()
|
||||||
|
|
||||||
|
// loadTracksByTitleAndArtist - queries by artist name
|
||||||
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||||
|
and, ok := opt.Filters.(squirrel.And)
|
||||||
|
if !ok || len(and) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
eq, hasEq := and[0].(squirrel.Eq)
|
||||||
|
if !hasEq {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, hasArtist := eq["order_artist_name"]
|
||||||
|
return hasArtist
|
||||||
|
})).Return(artistTracks, nil).Maybe()
|
||||||
|
}
|
||||||
|
|
||||||
Describe("matchSongsToLibrary priority matching", func() {
|
Describe("matchSongsToLibrary priority matching", func() {
|
||||||
var track model.MediaFile
|
var track model.MediaFile
|
||||||
|
|
||||||
@@ -261,26 +281,6 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||||
})
|
})
|
||||||
|
|
||||||
setupFuzzyExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
|
|
||||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
||||||
Return(returnedSongs, nil).Once()
|
|
||||||
|
|
||||||
// loadTracksByTitleAndArtist now queries by artist in a single pass
|
|
||||||
// Note: loadTracksByID and loadTracksByMBID return early when no IDs/MBIDs
|
|
||||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
||||||
and, ok := opt.Filters.(squirrel.And)
|
|
||||||
if !ok || len(and) < 2 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
eq, hasEq := and[0].(squirrel.Eq)
|
|
||||||
if !hasEq {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, hasArtist := eq["order_artist_name"]
|
|
||||||
return hasArtist
|
|
||||||
})).Return(artistTracks, nil).Maybe()
|
|
||||||
}
|
|
||||||
|
|
||||||
Context("with default threshold (85%)", func() {
|
Context("with default threshold (85%)", func() {
|
||||||
It("matches songs with remastered suffix", func() {
|
It("matches songs with remastered suffix", func() {
|
||||||
conf.Server.SimilarSongsMatchThreshold = 85
|
conf.Server.SimilarSongsMatchThreshold = 85
|
||||||
@@ -294,7 +294,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
|
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -424,7 +424,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -446,7 +446,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFuzzyExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
||||||
|
|
||||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
@@ -457,4 +457,171 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Duration filtering", func() {
|
||||||
|
var track model.MediaFile
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
|
||||||
|
|
||||||
|
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||||
|
|
||||||
|
// Setup for GetEntityByID to return the track
|
||||||
|
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||||
|
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||||
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when agent provides duration", func() {
|
||||||
|
It("prefers tracks with matching duration", func() {
|
||||||
|
// Agent returns song with duration 180000ms (180 seconds)
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||||
|
}
|
||||||
|
// Library has two versions: one matching duration, one not
|
||||||
|
correctMatch := model.MediaFile{
|
||||||
|
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
|
||||||
|
}
|
||||||
|
wrongDuration := model.MediaFile{
|
||||||
|
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("correct"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("matches within 3-second tolerance", func() {
|
||||||
|
// Agent returns song with duration 180000ms (180 seconds)
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||||
|
}
|
||||||
|
// Library has track with 182 seconds (within tolerance)
|
||||||
|
withinTolerance := model.MediaFile{
|
||||||
|
ID: "within-tolerance", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{withinTolerance})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("within-tolerance"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("excludes tracks outside 3-second tolerance when other matches exist", func() {
|
||||||
|
// Agent returns song with duration 180000ms (180 seconds)
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||||
|
}
|
||||||
|
// Library has one within tolerance, one outside
|
||||||
|
withinTolerance := model.MediaFile{
|
||||||
|
ID: "within", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||||
|
}
|
||||||
|
outsideTolerance := model.MediaFile{
|
||||||
|
ID: "outside", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{outsideTolerance, withinTolerance})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("within"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to normal matching when no duration matches", func() {
|
||||||
|
// Agent returns song with duration 180000ms
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||||
|
}
|
||||||
|
// Library only has tracks with very different duration
|
||||||
|
differentDuration := model.MediaFile{
|
||||||
|
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Should fall back and return the track despite duration mismatch
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("different"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to title match when duration-filtered tracks fail title threshold", func() {
|
||||||
|
// Agent returns "Similar Song" with duration 180000ms
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||||
|
}
|
||||||
|
// Library has:
|
||||||
|
// - differentTitle: matches duration but has different title (won't pass title threshold)
|
||||||
|
// - correctTitle: doesn't match duration but has correct title (should be found via fallback)
|
||||||
|
differentTitle := model.MediaFile{
|
||||||
|
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||||
|
}
|
||||||
|
correctTitle := model.MediaFile{
|
||||||
|
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Should fall back to all tracks and find the title match
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("correct-title"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when agent does not provide duration", func() {
|
||||||
|
It("matches without duration filtering (duration=0)", func() {
|
||||||
|
// Agent returns song without duration
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
|
||||||
|
}
|
||||||
|
// Library tracks with various durations should all be candidates
|
||||||
|
anyTrack := model.MediaFile{
|
||||||
|
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("any"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("edge cases", func() {
|
||||||
|
It("handles very short songs with duration tolerance", func() {
|
||||||
|
// 30-second song with 1-second difference (within 3-second tolerance)
|
||||||
|
returnedSongs := []agents.Song{
|
||||||
|
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
|
||||||
|
}
|
||||||
|
shortTrack := model.MediaFile{
|
||||||
|
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
|
||||||
|
|
||||||
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(songs).To(HaveLen(1))
|
||||||
|
Expect(songs[0].ID).To(Equal("short"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ type MetadataAgent interface {
|
|||||||
// GetAlbumImages retrieves images for an album.
|
// GetAlbumImages retrieves images for an album.
|
||||||
//nd:export name=nd_get_album_images
|
//nd:export name=nd_get_album_images
|
||||||
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
||||||
|
|
||||||
|
// GetSimilarSongsByTrack retrieves songs similar to a specific track.
|
||||||
|
//nd:export name=nd_get_similar_songs_by_track
|
||||||
|
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
|
||||||
|
|
||||||
|
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
|
||||||
|
//nd:export name=nd_get_similar_songs_by_album
|
||||||
|
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
|
||||||
|
|
||||||
|
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
|
||||||
|
//nd:export name=nd_get_similar_songs_by_artist
|
||||||
|
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistMBIDRequest is the request for GetArtistMBID.
|
// ArtistMBIDRequest is the request for GetArtistMBID.
|
||||||
@@ -122,7 +134,7 @@ type TopSongsRequest struct {
|
|||||||
Count int32 `json:"count"`
|
Count int32 `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SongRef is a reference to a song with name and optional MBID.
|
// SongRef is a reference to a song with metadata for matching.
|
||||||
type SongRef struct {
|
type SongRef struct {
|
||||||
// ID is the internal Navidrome mediafile ID (if known).
|
// ID is the internal Navidrome mediafile ID (if known).
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
@@ -130,6 +142,16 @@ type SongRef struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// MBID is the MusicBrainz ID for the song.
|
// MBID is the MusicBrainz ID for the song.
|
||||||
MBID string `json:"mbid,omitempty"`
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Artist is the artist name.
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
// ArtistMBID is the MusicBrainz artist ID.
|
||||||
|
ArtistMBID string `json:"artistMbid,omitempty"`
|
||||||
|
// Album is the album name.
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
// AlbumMBID is the MusicBrainz release ID.
|
||||||
|
AlbumMBID string `json:"albumMbid,omitempty"`
|
||||||
|
// Duration is the song duration in seconds.
|
||||||
|
Duration float32 `json:"duration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopSongsResponse is the response for GetArtistTopSongs.
|
// TopSongsResponse is the response for GetArtistTopSongs.
|
||||||
@@ -165,3 +187,49 @@ type AlbumImagesResponse struct {
|
|||||||
// Images is the list of album images.
|
// Images is the list of album images.
|
||||||
Images []ImageInfo `json:"images"`
|
Images []ImageInfo `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||||
|
type SimilarSongsByTrackRequest struct {
|
||||||
|
// ID is the internal Navidrome mediafile ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the track title.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Artist is the artist name.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// MBID is the MusicBrainz recording ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||||
|
type SimilarSongsByAlbumRequest struct {
|
||||||
|
// ID is the internal Navidrome album ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the album name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Artist is the album artist name.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// MBID is the MusicBrainz release ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||||
|
type SimilarSongsByArtistRequest struct {
|
||||||
|
// ID is the internal Navidrome artist ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the artist name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// MBID is the MusicBrainz artist ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||||
|
type SimilarSongsResponse struct {
|
||||||
|
// Songs is the list of similar songs.
|
||||||
|
Songs []SongRef `json:"songs"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ exports:
|
|||||||
output:
|
output:
|
||||||
$ref: '#/components/schemas/AlbumImagesResponse'
|
$ref: '#/components/schemas/AlbumImagesResponse'
|
||||||
contentType: application/json
|
contentType: application/json
|
||||||
|
nd_get_similar_songs_by_track:
|
||||||
|
description: GetSimilarSongsByTrack retrieves songs similar to a specific track.
|
||||||
|
input:
|
||||||
|
$ref: '#/components/schemas/SimilarSongsByTrackRequest'
|
||||||
|
contentType: application/json
|
||||||
|
output:
|
||||||
|
$ref: '#/components/schemas/SimilarSongsResponse'
|
||||||
|
contentType: application/json
|
||||||
|
nd_get_similar_songs_by_album:
|
||||||
|
description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
|
||||||
|
input:
|
||||||
|
$ref: '#/components/schemas/SimilarSongsByAlbumRequest'
|
||||||
|
contentType: application/json
|
||||||
|
output:
|
||||||
|
$ref: '#/components/schemas/SimilarSongsResponse'
|
||||||
|
contentType: application/json
|
||||||
|
nd_get_similar_songs_by_artist:
|
||||||
|
description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
|
||||||
|
input:
|
||||||
|
$ref: '#/components/schemas/SimilarSongsByArtistRequest'
|
||||||
|
contentType: application/json
|
||||||
|
output:
|
||||||
|
$ref: '#/components/schemas/SimilarSongsResponse'
|
||||||
|
contentType: application/json
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
AlbumImagesResponse:
|
AlbumImagesResponse:
|
||||||
@@ -229,8 +253,86 @@ components:
|
|||||||
$ref: '#/components/schemas/ArtistRef'
|
$ref: '#/components/schemas/ArtistRef'
|
||||||
required:
|
required:
|
||||||
- artists
|
- artists
|
||||||
|
SimilarSongsByAlbumRequest:
|
||||||
|
description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: ID is the internal Navidrome album ID.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name is the album name.
|
||||||
|
artist:
|
||||||
|
type: string
|
||||||
|
description: Artist is the album artist name.
|
||||||
|
mbid:
|
||||||
|
type: string
|
||||||
|
description: MBID is the MusicBrainz release ID (if known).
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: Count is the maximum number of similar songs to return.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- artist
|
||||||
|
- count
|
||||||
|
SimilarSongsByArtistRequest:
|
||||||
|
description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: ID is the internal Navidrome artist ID.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name is the artist name.
|
||||||
|
mbid:
|
||||||
|
type: string
|
||||||
|
description: MBID is the MusicBrainz artist ID (if known).
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: Count is the maximum number of similar songs to return.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- count
|
||||||
|
SimilarSongsByTrackRequest:
|
||||||
|
description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: ID is the internal Navidrome mediafile ID.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name is the track title.
|
||||||
|
artist:
|
||||||
|
type: string
|
||||||
|
description: Artist is the artist name.
|
||||||
|
mbid:
|
||||||
|
type: string
|
||||||
|
description: MBID is the MusicBrainz recording ID (if known).
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: Count is the maximum number of similar songs to return.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- artist
|
||||||
|
- count
|
||||||
|
SimilarSongsResponse:
|
||||||
|
description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||||
|
properties:
|
||||||
|
songs:
|
||||||
|
type: array
|
||||||
|
description: Songs is the list of similar songs.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SongRef'
|
||||||
|
required:
|
||||||
|
- songs
|
||||||
SongRef:
|
SongRef:
|
||||||
description: SongRef is a reference to a song with name and optional MBID.
|
description: SongRef is a reference to a song with metadata for matching.
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -241,6 +343,22 @@ components:
|
|||||||
mbid:
|
mbid:
|
||||||
type: string
|
type: string
|
||||||
description: MBID is the MusicBrainz ID for the song.
|
description: MBID is the MusicBrainz ID for the song.
|
||||||
|
artist:
|
||||||
|
type: string
|
||||||
|
description: Artist is the artist name.
|
||||||
|
artistMbid:
|
||||||
|
type: string
|
||||||
|
description: ArtistMBID is the MusicBrainz artist ID.
|
||||||
|
album:
|
||||||
|
type: string
|
||||||
|
description: Album is the album name.
|
||||||
|
albumMbid:
|
||||||
|
type: string
|
||||||
|
description: AlbumMBID is the MusicBrainz release ID.
|
||||||
|
duration:
|
||||||
|
type: number
|
||||||
|
format: float
|
||||||
|
description: Duration is the song duration in seconds.
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
TopSongsRequest:
|
TopSongsRequest:
|
||||||
|
|||||||
@@ -568,6 +568,18 @@ func skipSerializingFunc(goType string) string {
|
|||||||
return "String::is_empty"
|
return "String::is_empty"
|
||||||
case "bool":
|
case "bool":
|
||||||
return "std::ops::Not::not"
|
return "std::ops::Not::not"
|
||||||
|
case "int32":
|
||||||
|
return "is_zero_i32"
|
||||||
|
case "uint32":
|
||||||
|
return "is_zero_u32"
|
||||||
|
case "int64":
|
||||||
|
return "is_zero_i64"
|
||||||
|
case "uint64":
|
||||||
|
return "is_zero_u64"
|
||||||
|
case "float32":
|
||||||
|
return "is_zero_f32"
|
||||||
|
case "float64":
|
||||||
|
return "is_zero_f64"
|
||||||
default:
|
default:
|
||||||
return "Option::is_none"
|
return "Option::is_none"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1234,6 +1234,37 @@ type OnInitOutput struct {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("Rust Generation", func() {
|
var _ = Describe("Rust Generation", func() {
|
||||||
|
Describe("skipSerializingFunc", func() {
|
||||||
|
It("should return Option::is_none for pointer, slice, and map types", func() {
|
||||||
|
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
||||||
|
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
||||||
|
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
||||||
|
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
||||||
|
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return String::is_empty for string type", func() {
|
||||||
|
Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return std::ops::Not::not for bool type", func() {
|
||||||
|
Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return is_zero_* functions for numeric types", func() {
|
||||||
|
Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
|
||||||
|
Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
|
||||||
|
Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
|
||||||
|
Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
|
||||||
|
Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
|
||||||
|
Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should return Option::is_none for unknown types", func() {
|
||||||
|
Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("rustOutputType", func() {
|
Describe("rustOutputType", func() {
|
||||||
It("should convert Go primitives to Rust primitives", func() {
|
It("should convert Go primitives to Rust primitives", func() {
|
||||||
Expect(rustOutputType("bool")).To(Equal("bool"))
|
Expect(rustOutputType("bool")).To(Equal("bool"))
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ use serde::{Deserialize, Serialize};
|
|||||||
{{- if hasHashMap .Capability}}
|
{{- if hasHashMap .Capability}}
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|
||||||
{{- /* Generate type alias definitions */ -}}
|
{{- /* Generate type alias definitions */ -}}
|
||||||
|
|||||||
@@ -466,9 +466,7 @@ func RustDefaultValue(goType string) string {
|
|||||||
switch goType {
|
switch goType {
|
||||||
case "string":
|
case "string":
|
||||||
return `String::new()`
|
return `String::new()`
|
||||||
case "int", "int32":
|
case "int", "int32", "int64", "uint", "uint32", "uint64":
|
||||||
return "0"
|
|
||||||
case "int64":
|
|
||||||
return "0"
|
return "0"
|
||||||
case "float32", "float64":
|
case "float32", "float64":
|
||||||
return "0.0"
|
return "0.0"
|
||||||
@@ -602,6 +600,10 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
|
|||||||
return "i32"
|
return "i32"
|
||||||
case "int64":
|
case "int64":
|
||||||
return "i64"
|
return "i64"
|
||||||
|
case "uint", "uint32":
|
||||||
|
return "u32"
|
||||||
|
case "uint64":
|
||||||
|
return "u64"
|
||||||
case "float32":
|
case "float32":
|
||||||
return "f32"
|
return "f32"
|
||||||
case "float64":
|
case "float64":
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func buildExport(export Export) xtpExport {
|
|||||||
// isPrimitiveGoType returns true if the Go type is a primitive type.
|
// isPrimitiveGoType returns true if the Go type is a primitive type.
|
||||||
func isPrimitiveGoType(goType string) bool {
|
func isPrimitiveGoType(goType string) bool {
|
||||||
switch goType {
|
switch goType {
|
||||||
case "bool", "string", "int", "int32", "int64", "float32", "float64", "[]byte":
|
case "bool", "string", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "[]byte":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -302,6 +302,12 @@ func goTypeToXTPTypeAndFormat(goType string) (typ, format string) {
|
|||||||
return "integer", "int32"
|
return "integer", "int32"
|
||||||
case "int64":
|
case "int64":
|
||||||
return "integer", "int64"
|
return "integer", "int64"
|
||||||
|
case "uint", "uint32":
|
||||||
|
// XTP schema doesn't support unsigned formats; use int64 to hold full uint32 range
|
||||||
|
return "integer", "int64"
|
||||||
|
case "uint64":
|
||||||
|
// XTP schema doesn't support unsigned formats; use int64 (may lose precision for large values)
|
||||||
|
return "integer", "int64"
|
||||||
case "float32":
|
case "float32":
|
||||||
return "number", "float"
|
return "number", "float"
|
||||||
case "float64":
|
case "float64":
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const (
|
|||||||
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
|
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
|
||||||
FuncGetAlbumInfo = "nd_get_album_info"
|
FuncGetAlbumInfo = "nd_get_album_info"
|
||||||
FuncGetAlbumImages = "nd_get_album_images"
|
FuncGetAlbumImages = "nd_get_album_images"
|
||||||
|
FuncGetSimilarSongsByTrack = "nd_get_similar_songs_by_track"
|
||||||
|
FuncGetSimilarSongsByAlbum = "nd_get_similar_songs_by_album"
|
||||||
|
FuncGetSimilarSongsByArtist = "nd_get_similar_songs_by_artist"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -35,6 +38,9 @@ func init() {
|
|||||||
FuncGetArtistTopSongs,
|
FuncGetArtistTopSongs,
|
||||||
FuncGetAlbumInfo,
|
FuncGetAlbumInfo,
|
||||||
FuncGetAlbumImages,
|
FuncGetAlbumImages,
|
||||||
|
FuncGetSimilarSongsByTrack,
|
||||||
|
FuncGetSimilarSongsByAlbum,
|
||||||
|
FuncGetSimilarSongsByArtist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,12 +153,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m
|
|||||||
return nil, agents.ErrNotFound
|
return nil, agents.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
songs := make([]agents.Song, len(result.Songs))
|
return songRefsToAgentSongs(result.Songs), nil
|
||||||
for i, s := range result.Songs {
|
|
||||||
songs[i] = agents.Song{ID: s.ID, Name: s.Name, MBID: s.MBID}
|
|
||||||
}
|
|
||||||
|
|
||||||
return songs, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbumInfo retrieves album information
|
// GetAlbumInfo retrieves album information
|
||||||
@@ -195,6 +196,50 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s
|
|||||||
return images, nil
|
return images, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) {
|
||||||
|
result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result == nil || len(result.Songs) == 0 {
|
||||||
|
return nil, agents.ErrNotFound
|
||||||
|
}
|
||||||
|
return songRefsToAgentSongs(result.Songs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSimilarSongsByTrack retrieves songs similar to a specific track
|
||||||
|
func (a *MetadataAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||||
|
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByTrackRequest](ctx, a.plugin, FuncGetSimilarSongsByTrack, capabilities.SimilarSongsByTrackRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album
|
||||||
|
func (a *MetadataAgent) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||||
|
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByAlbumRequest](ctx, a.plugin, FuncGetSimilarSongsByAlbum, capabilities.SimilarSongsByAlbumRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog
|
||||||
|
func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
|
||||||
|
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
|
||||||
|
func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
|
||||||
|
songs := make([]agents.Song, len(refs))
|
||||||
|
for i, s := range refs {
|
||||||
|
songs[i] = agents.Song{
|
||||||
|
ID: s.ID,
|
||||||
|
Name: s.Name,
|
||||||
|
MBID: s.MBID,
|
||||||
|
Artist: s.Artist,
|
||||||
|
ArtistMBID: s.ArtistMBID,
|
||||||
|
Album: s.Album,
|
||||||
|
AlbumMBID: s.AlbumMBID,
|
||||||
|
Duration: uint32(s.Duration * 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return songs
|
||||||
|
}
|
||||||
|
|
||||||
// Verify interface implementations at compile time
|
// Verify interface implementations at compile time
|
||||||
var (
|
var (
|
||||||
_ agents.Interface = (*MetadataAgent)(nil)
|
_ agents.Interface = (*MetadataAgent)(nil)
|
||||||
@@ -206,4 +251,7 @@ var (
|
|||||||
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
|
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
|
||||||
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
|
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
|
||||||
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
|
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
|
||||||
|
_ agents.SimilarSongsByTrackRetriever = (*MetadataAgent)(nil)
|
||||||
|
_ agents.SimilarSongsByAlbumRetriever = (*MetadataAgent)(nil)
|
||||||
|
_ agents.SimilarSongsByArtistRetriever = (*MetadataAgent)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,6 +108,37 @@ var _ = Describe("MetadataAgent", Ordered, func() {
|
|||||||
Expect(images[0].Size).To(Equal(500))
|
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].Artist).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() {
|
var _ = Describe("MetadataAgent error handling", Ordered, func() {
|
||||||
@@ -186,6 +217,27 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() {
|
|||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
|
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() {
|
var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
|
||||||
@@ -255,6 +307,23 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
|
|||||||
retriever := partialAgent.(agents.AlbumImageRetriever)
|
retriever := partialAgent.(agents.AlbumImageRetriever)
|
||||||
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
|
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
|
||||||
Expect(err).To(MatchError(errNotImplemented))
|
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))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,7 +117,53 @@ type SimilarArtistsResponse struct {
|
|||||||
Artists []ArtistRef `json:"artists"`
|
Artists []ArtistRef `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SongRef is a reference to a song with name and optional MBID.
|
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||||
|
type SimilarSongsByAlbumRequest struct {
|
||||||
|
// ID is the internal Navidrome album ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the album name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Artist is the album artist name.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// MBID is the MusicBrainz release ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||||
|
type SimilarSongsByArtistRequest struct {
|
||||||
|
// ID is the internal Navidrome artist ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the artist name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// MBID is the MusicBrainz artist ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||||
|
type SimilarSongsByTrackRequest struct {
|
||||||
|
// ID is the internal Navidrome mediafile ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the track title.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Artist is the artist name.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// MBID is the MusicBrainz recording ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||||
|
type SimilarSongsResponse struct {
|
||||||
|
// Songs is the list of similar songs.
|
||||||
|
Songs []SongRef `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SongRef is a reference to a song with metadata for matching.
|
||||||
type SongRef struct {
|
type SongRef struct {
|
||||||
// ID is the internal Navidrome mediafile ID (if known).
|
// ID is the internal Navidrome mediafile ID (if known).
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
@@ -125,6 +171,16 @@ type SongRef struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// MBID is the MusicBrainz ID for the song.
|
// MBID is the MusicBrainz ID for the song.
|
||||||
MBID string `json:"mbid,omitempty"`
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Artist is the artist name.
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
// ArtistMBID is the MusicBrainz artist ID.
|
||||||
|
ArtistMBID string `json:"artistMbid,omitempty"`
|
||||||
|
// Album is the album name.
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
// AlbumMBID is the MusicBrainz release ID.
|
||||||
|
AlbumMBID string `json:"albumMbid,omitempty"`
|
||||||
|
// Duration is the song duration in seconds.
|
||||||
|
Duration float32 `json:"duration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopSongsRequest is the request for GetArtistTopSongs.
|
// TopSongsRequest is the request for GetArtistTopSongs.
|
||||||
@@ -193,6 +249,21 @@ type AlbumInfoProvider interface {
|
|||||||
// AlbumImagesProvider provides the GetAlbumImages function.
|
// AlbumImagesProvider provides the GetAlbumImages function.
|
||||||
type AlbumImagesProvider interface {
|
type AlbumImagesProvider interface {
|
||||||
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function.
|
||||||
|
type SimilarSongsByTrackProvider interface {
|
||||||
|
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function.
|
||||||
|
type SimilarSongsByAlbumProvider interface {
|
||||||
|
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function.
|
||||||
|
type SimilarSongsByArtistProvider interface {
|
||||||
|
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
|
||||||
} // Internal implementation holders
|
} // Internal implementation holders
|
||||||
var (
|
var (
|
||||||
artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
|
artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
|
||||||
@@ -203,6 +274,9 @@ var (
|
|||||||
artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error)
|
artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error)
|
||||||
albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error)
|
albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error)
|
||||||
albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error)
|
albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error)
|
||||||
|
similarSongsByTrackImpl func(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
|
||||||
|
similarSongsByAlbumImpl func(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
|
||||||
|
similarSongsByArtistImpl func(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register registers a metadata implementation.
|
// Register registers a metadata implementation.
|
||||||
@@ -232,6 +306,15 @@ func Register(impl Metadata) {
|
|||||||
if p, ok := impl.(AlbumImagesProvider); ok {
|
if p, ok := impl.(AlbumImagesProvider); ok {
|
||||||
albumImagesImpl = p.GetAlbumImages
|
albumImagesImpl = p.GetAlbumImages
|
||||||
}
|
}
|
||||||
|
if p, ok := impl.(SimilarSongsByTrackProvider); ok {
|
||||||
|
similarSongsByTrackImpl = p.GetSimilarSongsByTrack
|
||||||
|
}
|
||||||
|
if p, ok := impl.(SimilarSongsByAlbumProvider); ok {
|
||||||
|
similarSongsByAlbumImpl = p.GetSimilarSongsByAlbum
|
||||||
|
}
|
||||||
|
if p, ok := impl.(SimilarSongsByArtistProvider); ok {
|
||||||
|
similarSongsByArtistImpl = p.GetSimilarSongsByArtist
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||||
@@ -453,3 +536,84 @@ func _NdGetAlbumImages() int32 {
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_get_similar_songs_by_track
|
||||||
|
func _NdGetSimilarSongsByTrack() int32 {
|
||||||
|
if similarSongsByTrackImpl == nil {
|
||||||
|
// Return standard code - host will skip this plugin gracefully
|
||||||
|
return NotImplementedCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var input SimilarSongsByTrackRequest
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := similarSongsByTrackImpl(input)
|
||||||
|
if err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_get_similar_songs_by_album
|
||||||
|
func _NdGetSimilarSongsByAlbum() int32 {
|
||||||
|
if similarSongsByAlbumImpl == nil {
|
||||||
|
// Return standard code - host will skip this plugin gracefully
|
||||||
|
return NotImplementedCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var input SimilarSongsByAlbumRequest
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := similarSongsByAlbumImpl(input)
|
||||||
|
if err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_get_similar_songs_by_artist
|
||||||
|
func _NdGetSimilarSongsByArtist() int32 {
|
||||||
|
if similarSongsByArtistImpl == nil {
|
||||||
|
// Return standard code - host will skip this plugin gracefully
|
||||||
|
return NotImplementedCode
|
||||||
|
}
|
||||||
|
|
||||||
|
var input SimilarSongsByArtistRequest
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := similarSongsByArtistImpl(input)
|
||||||
|
if err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,7 +114,53 @@ type SimilarArtistsResponse struct {
|
|||||||
Artists []ArtistRef `json:"artists"`
|
Artists []ArtistRef `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SongRef is a reference to a song with name and optional MBID.
|
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||||
|
type SimilarSongsByAlbumRequest struct {
|
||||||
|
// ID is the internal Navidrome album ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the album name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Artist is the album artist name.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// MBID is the MusicBrainz release ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||||
|
type SimilarSongsByArtistRequest struct {
|
||||||
|
// ID is the internal Navidrome artist ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the artist name.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// MBID is the MusicBrainz artist ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||||
|
type SimilarSongsByTrackRequest struct {
|
||||||
|
// ID is the internal Navidrome mediafile ID.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name is the track title.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Artist is the artist name.
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
// MBID is the MusicBrainz recording ID (if known).
|
||||||
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Count is the maximum number of similar songs to return.
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||||
|
type SimilarSongsResponse struct {
|
||||||
|
// Songs is the list of similar songs.
|
||||||
|
Songs []SongRef `json:"songs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SongRef is a reference to a song with metadata for matching.
|
||||||
type SongRef struct {
|
type SongRef struct {
|
||||||
// ID is the internal Navidrome mediafile ID (if known).
|
// ID is the internal Navidrome mediafile ID (if known).
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
@@ -122,6 +168,16 @@ type SongRef struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// MBID is the MusicBrainz ID for the song.
|
// MBID is the MusicBrainz ID for the song.
|
||||||
MBID string `json:"mbid,omitempty"`
|
MBID string `json:"mbid,omitempty"`
|
||||||
|
// Artist is the artist name.
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
// ArtistMBID is the MusicBrainz artist ID.
|
||||||
|
ArtistMBID string `json:"artistMbid,omitempty"`
|
||||||
|
// Album is the album name.
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
// AlbumMBID is the MusicBrainz release ID.
|
||||||
|
AlbumMBID string `json:"albumMbid,omitempty"`
|
||||||
|
// Duration is the song duration in seconds.
|
||||||
|
Duration float32 `json:"duration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopSongsRequest is the request for GetArtistTopSongs.
|
// TopSongsRequest is the request for GetArtistTopSongs.
|
||||||
@@ -192,6 +248,21 @@ type AlbumImagesProvider interface {
|
|||||||
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function.
|
||||||
|
type SimilarSongsByTrackProvider interface {
|
||||||
|
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function.
|
||||||
|
type SimilarSongsByAlbumProvider interface {
|
||||||
|
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function.
|
||||||
|
type SimilarSongsByArtistProvider interface {
|
||||||
|
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
// NotImplementedCode is the standard return code for unimplemented functions.
|
// NotImplementedCode is the standard return code for unimplemented functions.
|
||||||
const NotImplementedCode int32 = -2
|
const NotImplementedCode int32 = -2
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
/// AlbumImagesResponse is the response for GetAlbumImages.
|
/// AlbumImagesResponse is the response for GetAlbumImages.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -150,7 +164,72 @@ pub struct SimilarArtistsResponse {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub artists: Vec<ArtistRef>,
|
pub artists: Vec<ArtistRef>,
|
||||||
}
|
}
|
||||||
/// SongRef is a reference to a song with name and optional MBID.
|
/// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SimilarSongsByAlbumRequest {
|
||||||
|
/// ID is the internal Navidrome album ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
/// Name is the album name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// Artist is the album artist name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: String,
|
||||||
|
/// MBID is the MusicBrainz release ID (if known).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbid: String,
|
||||||
|
/// Count is the maximum number of similar songs to return.
|
||||||
|
#[serde(default)]
|
||||||
|
pub count: i32,
|
||||||
|
}
|
||||||
|
/// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SimilarSongsByArtistRequest {
|
||||||
|
/// ID is the internal Navidrome artist ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
/// Name is the artist name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// MBID is the MusicBrainz artist ID (if known).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbid: String,
|
||||||
|
/// Count is the maximum number of similar songs to return.
|
||||||
|
#[serde(default)]
|
||||||
|
pub count: i32,
|
||||||
|
}
|
||||||
|
/// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SimilarSongsByTrackRequest {
|
||||||
|
/// ID is the internal Navidrome mediafile ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: String,
|
||||||
|
/// Name is the track title.
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
/// Artist is the artist name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub artist: String,
|
||||||
|
/// MBID is the MusicBrainz recording ID (if known).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub mbid: String,
|
||||||
|
/// Count is the maximum number of similar songs to return.
|
||||||
|
#[serde(default)]
|
||||||
|
pub count: i32,
|
||||||
|
}
|
||||||
|
/// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SimilarSongsResponse {
|
||||||
|
/// Songs is the list of similar songs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub songs: Vec<SongRef>,
|
||||||
|
}
|
||||||
|
/// SongRef is a reference to a song with metadata for matching.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SongRef {
|
pub struct SongRef {
|
||||||
@@ -163,6 +242,21 @@ pub struct SongRef {
|
|||||||
/// MBID is the MusicBrainz ID for the song.
|
/// MBID is the MusicBrainz ID for the song.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub mbid: String,
|
pub mbid: String,
|
||||||
|
/// Artist is the artist name.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub artist: String,
|
||||||
|
/// ArtistMBID is the MusicBrainz artist ID.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub artist_mbid: String,
|
||||||
|
/// Album is the album name.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub album: String,
|
||||||
|
/// AlbumMBID is the MusicBrainz release ID.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub album_mbid: String,
|
||||||
|
/// Duration is the song duration in seconds.
|
||||||
|
#[serde(default, skip_serializing_if = "is_zero_f32")]
|
||||||
|
pub duration: f32,
|
||||||
}
|
}
|
||||||
/// TopSongsRequest is the request for GetArtistTopSongs.
|
/// TopSongsRequest is the request for GetArtistTopSongs.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -377,3 +471,66 @@ macro_rules! register_metadata_album_images {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function.
|
||||||
|
pub trait SimilarSongsByTrackProvider {
|
||||||
|
fn get_similar_songs_by_track(&self, req: SimilarSongsByTrackRequest) -> Result<SimilarSongsResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the get_similar_songs_by_track export.
|
||||||
|
/// This macro generates the WASM export function for this method.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! register_metadata_similar_songs_by_track {
|
||||||
|
($plugin_type:ty) => {
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn nd_get_similar_songs_by_track(
|
||||||
|
req: extism_pdk::Json<$crate::metadata::SimilarSongsByTrackRequest>
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
let result = $crate::metadata::SimilarSongsByTrackProvider::get_similar_songs_by_track(&plugin, req.into_inner())?;
|
||||||
|
Ok(extism_pdk::Json(result))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function.
|
||||||
|
pub trait SimilarSongsByAlbumProvider {
|
||||||
|
fn get_similar_songs_by_album(&self, req: SimilarSongsByAlbumRequest) -> Result<SimilarSongsResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the get_similar_songs_by_album export.
|
||||||
|
/// This macro generates the WASM export function for this method.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! register_metadata_similar_songs_by_album {
|
||||||
|
($plugin_type:ty) => {
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn nd_get_similar_songs_by_album(
|
||||||
|
req: extism_pdk::Json<$crate::metadata::SimilarSongsByAlbumRequest>
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
let result = $crate::metadata::SimilarSongsByAlbumProvider::get_similar_songs_by_album(&plugin, req.into_inner())?;
|
||||||
|
Ok(extism_pdk::Json(result))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function.
|
||||||
|
pub trait SimilarSongsByArtistProvider {
|
||||||
|
fn get_similar_songs_by_artist(&self, req: SimilarSongsByArtistRequest) -> Result<SimilarSongsResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the get_similar_songs_by_artist export.
|
||||||
|
/// This macro generates the WASM export function for this method.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! register_metadata_similar_songs_by_artist {
|
||||||
|
($plugin_type:ty) => {
|
||||||
|
#[extism_pdk::plugin_fn]
|
||||||
|
pub fn nd_get_similar_songs_by_artist(
|
||||||
|
req: extism_pdk::Json<$crate::metadata::SimilarSongsByArtistRequest>
|
||||||
|
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
let result = $crate::metadata::SimilarSongsByArtistProvider::get_similar_songs_by_artist(&plugin, req.into_inner())?;
|
||||||
|
Ok(extism_pdk::Json(result))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
/// SchedulerCallbackRequest is the request provided when a scheduled task fires.
|
/// SchedulerCallbackRequest is the request provided when a scheduled task fires.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
/// ScrobblerError represents an error type for scrobbling operations.
|
/// ScrobblerError represents an error type for scrobbling operations.
|
||||||
pub type ScrobblerError = &'static str;
|
pub type ScrobblerError = &'static str;
|
||||||
/// ScrobblerErrorNotAuthorized indicates the user is not authorized.
|
/// ScrobblerErrorNotAuthorized indicates the user is not authorized.
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Helper functions for skip_serializing_if with numeric types
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||||
/// OnBinaryMessageRequest is the request provided when a binary message is received.
|
/// OnBinaryMessageRequest is the request provided when a binary message is received.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
+60
@@ -120,4 +120,64 @@ func (t *testMetadataAgent) GetAlbumImages(input metadata.AlbumRequest) (*metada
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsByTrackRequest) (*metadata.SimilarSongsResponse, error) {
|
||||||
|
if err := checkConfigError(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := int(input.Count)
|
||||||
|
if count == 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
|
songs := make([]metadata.SongRef, 0, count)
|
||||||
|
for i := range count {
|
||||||
|
songs = append(songs, metadata.SongRef{
|
||||||
|
ID: "similar-track-id-" + strconv.Itoa(i+1),
|
||||||
|
Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1),
|
||||||
|
MBID: "similar-mbid-" + strconv.Itoa(i+1),
|
||||||
|
Artist: input.Artist,
|
||||||
|
ArtistMBID: "artist-mbid-" + strconv.Itoa(i+1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &metadata.SimilarSongsResponse{Songs: songs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsByAlbumRequest) (*metadata.SimilarSongsResponse, error) {
|
||||||
|
if err := checkConfigError(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := int(input.Count)
|
||||||
|
if count == 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
|
songs := make([]metadata.SongRef, 0, count)
|
||||||
|
for i := range count {
|
||||||
|
songs = append(songs, metadata.SongRef{
|
||||||
|
ID: "album-similar-id-" + strconv.Itoa(i+1),
|
||||||
|
Name: "Album Similar #" + strconv.Itoa(i+1),
|
||||||
|
Artist: input.Artist,
|
||||||
|
Album: input.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &metadata.SimilarSongsResponse{Songs: songs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsByArtistRequest) (*metadata.SimilarSongsResponse, error) {
|
||||||
|
if err := checkConfigError(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
count := int(input.Count)
|
||||||
|
if count == 0 {
|
||||||
|
count = 5
|
||||||
|
}
|
||||||
|
songs := make([]metadata.SongRef, 0, count)
|
||||||
|
for i := range count {
|
||||||
|
songs = append(songs, metadata.SongRef{
|
||||||
|
ID: "artist-similar-id-" + strconv.Itoa(i+1),
|
||||||
|
Name: input.Name + " Style Song #" + strconv.Itoa(i+1),
|
||||||
|
Artist: input.Name + " Similar Artist",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &metadata.SimilarSongsResponse{Songs: songs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {}
|
func main() {}
|
||||||
|
|||||||
Reference in New Issue
Block a user