mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
feat(plugins): add similar songs retrieval functions and improve duration consistency (#4933)
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 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 / 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
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 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 / 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
* 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:
parent
4d4740b83b
commit
fda35dd8ce
20 changed files with 1147 additions and 70 deletions
|
|
@ -135,8 +135,9 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st
|
|||
|
||||
res := slice.Map(tracks, func(r Track) agents.Song {
|
||||
return agents.Song{
|
||||
Name: r.Title,
|
||||
Album: r.Album.Title,
|
||||
Name: r.Title,
|
||||
Album: r.Album.Title,
|
||||
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
|
||||
}
|
||||
})
|
||||
return res, nil
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ type Song struct {
|
|||
ArtistMBID string
|
||||
Album string
|
||||
AlbumMBID string
|
||||
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
|
|||
46
core/external/provider_matching.go
vendored
46
core/external/provider_matching.go
vendored
|
|
@ -3,6 +3,7 @@ package external
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -12,6 +13,11 @@ import (
|
|||
"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
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
|
|
@ -175,6 +181,7 @@ type songQuery struct {
|
|||
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
||||
album string // Sanitized album name (optional, for specificity scoring)
|
||||
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
|
||||
|
|
@ -282,12 +289,50 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
|||
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.
|
||||
// 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:
|
||||
// 1. Highest title similarity
|
||||
// 2. Highest specificity level
|
||||
// 3. Highest album similarity (as final tiebreaker)
|
||||
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
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
|
@ -338,6 +383,7 @@ func (e *provider) buildTitleQueries(songs []agents.Song, idMatches, mbidMatches
|
|||
artistMBID: s.ArtistMBID,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
durationMs: s.Duration,
|
||||
})
|
||||
}
|
||||
return queries
|
||||
|
|
|
|||
223
core/external/provider_matching_test.go
vendored
223
core/external/provider_matching_test.go
vendored
|
|
@ -41,6 +41,26 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||
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() {
|
||||
var track model.MediaFile
|
||||
|
||||
|
|
@ -261,26 +281,6 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||
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() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
|
@ -294,7 +294,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
|||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, artistTracks)
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
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)",
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
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.
|
||||
//nd:export name=nd_get_album_images
|
||||
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.
|
||||
|
|
@ -122,7 +134,7 @@ type TopSongsRequest struct {
|
|||
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 {
|
||||
// ID is the internal Navidrome mediafile ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
|
|
@ -130,6 +142,16 @@ type SongRef struct {
|
|||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
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.
|
||||
|
|
@ -165,3 +187,49 @@ type AlbumImagesResponse struct {
|
|||
// Images is the list of album 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:
|
||||
$ref: '#/components/schemas/AlbumImagesResponse'
|
||||
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:
|
||||
schemas:
|
||||
AlbumImagesResponse:
|
||||
|
|
@ -229,8 +253,86 @@ components:
|
|||
$ref: '#/components/schemas/ArtistRef'
|
||||
required:
|
||||
- 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:
|
||||
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:
|
||||
id:
|
||||
type: string
|
||||
|
|
@ -241,6 +343,22 @@ components:
|
|||
mbid:
|
||||
type: string
|
||||
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:
|
||||
- name
|
||||
TopSongsRequest:
|
||||
|
|
|
|||
|
|
@ -568,6 +568,18 @@ func skipSerializingFunc(goType string) string {
|
|||
return "String::is_empty"
|
||||
case "bool":
|
||||
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:
|
||||
return "Option::is_none"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1234,6 +1234,37 @@ type OnInitOutput struct {
|
|||
})
|
||||
|
||||
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() {
|
||||
It("should convert Go primitives to Rust primitives", func() {
|
||||
Expect(rustOutputType("bool")).To(Equal("bool"))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,20 @@ use serde::{Deserialize, Serialize};
|
|||
{{- if hasHashMap .Capability}}
|
||||
use std::collections::HashMap;
|
||||
{{- 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}}
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
|
|
|
|||
|
|
@ -466,9 +466,7 @@ func RustDefaultValue(goType string) string {
|
|||
switch goType {
|
||||
case "string":
|
||||
return `String::new()`
|
||||
case "int", "int32":
|
||||
return "0"
|
||||
case "int64":
|
||||
case "int", "int32", "int64", "uint", "uint32", "uint64":
|
||||
return "0"
|
||||
case "float32", "float64":
|
||||
return "0.0"
|
||||
|
|
@ -602,6 +600,10 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
|
|||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
case "uint", "uint32":
|
||||
return "u32"
|
||||
case "uint64":
|
||||
return "u64"
|
||||
case "float32":
|
||||
return "f32"
|
||||
case "float64":
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ func buildExport(export Export) xtpExport {
|
|||
// isPrimitiveGoType returns true if the Go type is a primitive type.
|
||||
func isPrimitiveGoType(goType string) bool {
|
||||
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 false
|
||||
|
|
@ -302,6 +302,12 @@ func goTypeToXTPTypeAndFormat(goType string) (typ, format string) {
|
|||
return "integer", "int32"
|
||||
case "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":
|
||||
return "number", "float"
|
||||
case "float64":
|
||||
|
|
|
|||
|
|
@ -14,14 +14,17 @@ const CapabilityMetadataAgent Capability = "MetadataAgent"
|
|||
|
||||
// Export function names (snake_case as per design)
|
||||
const (
|
||||
FuncGetArtistMBID = "nd_get_artist_mbid"
|
||||
FuncGetArtistURL = "nd_get_artist_url"
|
||||
FuncGetArtistBiography = "nd_get_artist_biography"
|
||||
FuncGetSimilarArtists = "nd_get_similar_artists"
|
||||
FuncGetArtistImages = "nd_get_artist_images"
|
||||
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
|
||||
FuncGetAlbumInfo = "nd_get_album_info"
|
||||
FuncGetAlbumImages = "nd_get_album_images"
|
||||
FuncGetArtistMBID = "nd_get_artist_mbid"
|
||||
FuncGetArtistURL = "nd_get_artist_url"
|
||||
FuncGetArtistBiography = "nd_get_artist_biography"
|
||||
FuncGetSimilarArtists = "nd_get_similar_artists"
|
||||
FuncGetArtistImages = "nd_get_artist_images"
|
||||
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
|
||||
FuncGetAlbumInfo = "nd_get_album_info"
|
||||
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() {
|
||||
|
|
@ -35,6 +38,9 @@ func init() {
|
|||
FuncGetArtistTopSongs,
|
||||
FuncGetAlbumInfo,
|
||||
FuncGetAlbumImages,
|
||||
FuncGetSimilarSongsByTrack,
|
||||
FuncGetSimilarSongsByAlbum,
|
||||
FuncGetSimilarSongsByArtist,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -147,12 +153,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m
|
|||
return nil, agents.ErrNotFound
|
||||
}
|
||||
|
||||
songs := make([]agents.Song, len(result.Songs))
|
||||
for i, s := range result.Songs {
|
||||
songs[i] = agents.Song{ID: s.ID, Name: s.Name, MBID: s.MBID}
|
||||
}
|
||||
|
||||
return songs, nil
|
||||
return songRefsToAgentSongs(result.Songs), nil
|
||||
}
|
||||
|
||||
// GetAlbumInfo retrieves album information
|
||||
|
|
@ -195,15 +196,62 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s
|
|||
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
|
||||
var (
|
||||
_ agents.Interface = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.Interface = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
|
||||
_ agents.AlbumInfoRetriever = (*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))
|
||||
})
|
||||
})
|
||||
|
||||
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() {
|
||||
|
|
@ -186,6 +217,27 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() {
|
|||
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() {
|
||||
|
|
@ -255,6 +307,23 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, 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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -117,7 +117,53 @@ type SimilarArtistsResponse struct {
|
|||
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 {
|
||||
// ID is the internal Navidrome mediafile ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
|
|
@ -125,6 +171,16 @@ type SongRef struct {
|
|||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
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.
|
||||
|
|
@ -193,16 +249,34 @@ type AlbumInfoProvider interface {
|
|||
// AlbumImagesProvider provides the GetAlbumImages function.
|
||||
type AlbumImagesProvider interface {
|
||||
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
|
||||
var (
|
||||
artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
|
||||
artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error)
|
||||
artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error)
|
||||
similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
|
||||
artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error)
|
||||
artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error)
|
||||
albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error)
|
||||
albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error)
|
||||
artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
|
||||
artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error)
|
||||
artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error)
|
||||
similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
|
||||
artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error)
|
||||
artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error)
|
||||
albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, 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.
|
||||
|
|
@ -232,6 +306,15 @@ func Register(impl Metadata) {
|
|||
if p, ok := impl.(AlbumImagesProvider); ok {
|
||||
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.
|
||||
|
|
@ -453,3 +536,84 @@ func _NdGetAlbumImages() int32 {
|
|||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// ID is the internal Navidrome mediafile ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
|
|
@ -122,6 +168,16 @@ type SongRef struct {
|
|||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
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.
|
||||
|
|
@ -192,6 +248,21 @@ type AlbumImagesProvider interface {
|
|||
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.
|
||||
const NotImplementedCode int32 = -2
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@
|
|||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -150,7 +164,72 @@ pub struct SimilarArtistsResponse {
|
|||
#[serde(default)]
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SongRef {
|
||||
|
|
@ -163,6 +242,21 @@ pub struct SongRef {
|
|||
/// MBID is the MusicBrainz ID for the song.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
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.
|
||||
#[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.
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@
|
|||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
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.
|
||||
pub type ScrobblerError = &'static str;
|
||||
/// ScrobblerErrorNotAuthorized indicates the user is not authorized.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,20 @@
|
|||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
60
plugins/testdata/test-metadata-agent/main.go
vendored
60
plugins/testdata/test-metadata-agent/main.go
vendored
|
|
@ -120,4 +120,64 @@ func (t *testMetadataAgent) GetAlbumImages(input metadata.AlbumRequest) (*metada
|
|||
}, 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() {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue