diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 7272bf9ae..a6806595e 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -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 diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index d0805818e..94373ec1a 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -40,6 +40,7 @@ type Song struct { ArtistMBID string Album string AlbumMBID string + Duration uint32 // Duration in milliseconds, 0 means unknown } var ( diff --git a/core/external/provider_matching.go b/core/external/provider_matching.go index 6bf393f77..0bbe4fa35 100644 --- a/core/external/provider_matching.go +++ b/core/external/provider_matching.go @@ -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 diff --git a/core/external/provider_matching_test.go b/core/external/provider_matching_test.go index cb6b33e65..245209b21 100644 --- a/core/external/provider_matching_test.go +++ b/core/external/provider_matching_test.go @@ -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")) + }) + }) + }) }) diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index fbe89a2be..7070637ff 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -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"` +} diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index ebc4a2ba0..07ae939e4 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -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: diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 26df6fc17..69e232565 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -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" } diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 0fcd0da98..4a189ddd2 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -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")) diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl index 01e6513ac..597a17338 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl @@ -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 */ -}} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go index 5fd7e892c..7bd2bcc3f 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -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": diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go index 200e72adb..db30262cc 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -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": diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 7db2142d1..67a7e373d 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -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) ) diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index b4c37a88c..694cef716 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -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)) }) }) diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index 6898468a5..46f6da5d8 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -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 +} diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index 07336142e..7b0a1109f 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -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 diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index df7695f0e..24ca0b6df 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -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, } -/// 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 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; +} + +/// 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> { + 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; +} + +/// 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> { + 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; +} + +/// 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> { + let plugin = <$plugin_type>::default(); + let result = $crate::metadata::SimilarSongsByArtistProvider::get_similar_songs_by_artist(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs index a77688a6d..53b8564ee 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs @@ -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")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 7a777496d..9dbedd040 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -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. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs index 81374ebe8..b077110d3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs @@ -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")] diff --git a/plugins/testdata/test-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go index b72682c3f..2ff2a0bd6 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -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() {}