feat(plugins): add similar songs retrieval functions and improve duration consistency (#4933)

* feat: add duration filtering for similar songs matching

Signed-off-by: Deluan <deluan@navidrome.org>

* test: refactor expectations for similar songs in provider matching tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(plugins): add functions to retrieve similar songs by track, album, and artist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): support uint32 in ndpgen

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): update duration field to use seconds as float instead of milliseconds as uint32

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add helper functions for Rust's skip_serializing_if with numeric types

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(provider): enhance track matching logic to fallback to title match when duration-filtered tracks fail

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-01-26 18:28:41 -05:00
committed by GitHub
parent 4d4740b83b
commit fda35dd8ce
20 changed files with 1147 additions and 70 deletions
+69 -1
View File
@@ -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"`
}
+119 -1
View File
@@ -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:
+12
View File
@@ -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 */ -}}
+5 -3
View File
@@ -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":
+7 -1
View File
@@ -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":
+71 -23
View File
@@ -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)
)
+69
View File
@@ -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))
})
})
+173 -9
View File
@@ -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
}
+72 -1
View File
@@ -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
View File
@@ -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() {}