diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index a64897509..7336124fc 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -114,7 +114,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -126,5 +127,14 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index cae23eadc..6e528c7e7 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -351,7 +351,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -363,6 +364,15 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name SongRef: @@ -387,10 +397,16 @@ components: description: ISRC is the International Standard Recording Code for the song. artist: type: string - description: Artist is the artist name. + description: |- + Artist is the artist name. + + Deprecated: use Artists. artistMbid: type: string - description: ArtistMBID is the MusicBrainz artist ID. + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. artists: type: array description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. @@ -405,6 +421,17 @@ components: duration: type: number format: float - description: Duration is the song duration in seconds. + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. required: - name diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 07af8ec7a..2b862b964 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -184,7 +184,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -196,5 +197,14 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index f0951fcec..a336fbce8 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -61,7 +61,8 @@ components: description: |- ArtistRef is the minimal information a plugin returns for Navidrome to match an artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs), never + carries only matching keys (name and optional internal/MusicBrainz IDs) plus a + few projection fields used when describing a track's participants, never descriptive data such as biographies or images. properties: id: @@ -73,6 +74,15 @@ components: mbid: type: string description: MBID is the MusicBrainz ID for the artist. + sortName: + type: string + description: SortName is the artist name used for sorting (if known). + role: + type: string + description: Role is the participation category (e.g. "artist", "composer", "performer"). + subRole: + type: string + description: SubRole is a specialization within Role (e.g. the instrument for a performer). required: - name SongRef: @@ -97,10 +107,16 @@ components: description: ISRC is the International Standard Recording Code for the song. artist: type: string - description: Artist is the artist name. + description: |- + Artist is the artist name. + + Deprecated: use Artists. artistMbid: type: string - description: ArtistMBID is the MusicBrainz artist ID. + description: |- + ArtistMBID is the MusicBrainz artist ID. + + Deprecated: use Artists. artists: type: array description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. @@ -115,6 +131,17 @@ components: duration: type: number format: float - description: Duration is the song duration in seconds. + description: |- + Duration is the song duration in seconds. + + Deprecated: use DurationMs, which carries millisecond precision. When + DurationMs is non-zero it takes precedence; Duration is kept only for + backwards compatibility with plugins that still send seconds. + durationMs: + type: integer + format: int64 + description: |- + DurationMs is the song duration in milliseconds. It supersedes Duration + when non-zero. required: - name diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index d52d6dcbf..15b97e1ae 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -287,6 +287,42 @@ var _ = Describe("Generator", func() { Expect(codeStr).To(ContainSubstring(`"encoding/json"`)) Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) }) + + It("imports the shared types package when a method references types directly", func() { + svc := Service{ + Name: "Matcher", + Interface: "MatcherService", + Methods: []Method{ + { + Name: "MatchSongs", + HasError: true, + Params: []Param{NewParam("songs", "[]types.SongRef")}, + Returns: []Param{NewParam("results", "[]*types.Track")}, + }, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + Expect(codeStr).To(ContainSubstring("Songs []types.SongRef")) + }) + + It("does not import the shared types package when no method references types", func() { + svc := Service{ + Name: "Test", + Interface: "TestService", + Methods: []Method{ + {Name: "Method", Params: []Param{NewParam("count", "int32")}}, + }, + } + + code, err := GenerateHost(svc, "host") + Expect(err).NotTo(HaveOccurred()) + Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) + }) }) Describe("toJSONName", func() { diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 4b832d22d..9f305efec 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -85,19 +85,24 @@ func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Servic return nil, err } - // First pass: collect all type aliases from every file so that an alias - // declared in one file is visible when resolving types in a sibling file. + // First pass: collect all struct definitions and type aliases from every file + // so that a struct or alias declared in one file is visible when resolving + // types in a sibling file. + pkgStructMap := make(map[string]StructDef) pkgAliasMap := make(map[string]TypeAlias) for _, pf := range parsed { + for _, s := range parseStructs(pf.file) { + pkgStructMap[s.Name] = s + } for _, a := range parseTypeAliases(pf.file) { pkgAliasMap[a.Name] = a } } - // Second pass: parse services using the package-level alias map. + // Second pass: parse services using the package-level maps. var services []Service for _, pf := range parsed { - svcList, err := parseServiceFile(pf.file, pkgAliasMap, shared) + svcList, err := parseServiceFile(pf.file, pkgStructMap, pkgAliasMap, shared) if err != nil { return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) } @@ -484,15 +489,10 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri } // parseServiceFile parses a single Go source file and extracts host services. -// pkgAliasMap is the package-wide alias map built from all files in the package. -func parseServiceFile(f *ast.File, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { - // Collect all struct definitions in the file. - allStructs := parseStructs(f) - structMap := make(map[string]StructDef) - for _, s := range allStructs { - structMap[s.Name] = s - } - +// pkgStructMap and pkgAliasMap are the package-wide struct and alias maps built +// from all files in the package, so a host-service interface can reference types +// defined in a sibling file. +func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { var services []Service for _, decl := range f.Decls { @@ -569,9 +569,13 @@ func parseServiceFile(f *ast.File, pkgAliasMap map[string]TypeAlias, shared map[ } service.SharedAliases = sharedAliases + // Recursively collect all struct dependencies so types referenced only + // transitively (e.g. a field type of a referenced struct) are attached. + collectAllStructDependencies(referencedTypes, pkgStructMap) + // Attach referenced structs to the service (sorted for stable output) for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { - if s, exists := structMap[typeName]; exists { + if s, exists := pkgStructMap[typeName]; exists { service.Structs = append(service.Structs, s) } } diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go index 93d00f657..bfdb93db5 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -213,6 +213,64 @@ type RegularInterface interface { Expect(services).To(BeEmpty()) }) + It("should resolve structs defined in a sibling file of the same package", func() { + // types.go defines Track and Artist — no host service here + typesSrc := `package host + +// Artist is a track participant. +type Artist struct { + // ID is the artist identifier. + ID string ` + "`json:\"id\"`" + ` + // Name is the artist name. + Name string ` + "`json:\"name\"`" + ` +} + +// Track is a media file projection. +type Track struct { + // ID is the track identifier. + ID string ` + "`json:\"id\"`" + ` + // Title is the track title. + Title string ` + "`json:\"title\"`" + ` + // Participants maps role to artists (transitive dependency test). + Participants map[string][]Artist ` + "`json:\"participants\"`" + ` +} +` + // service.go defines the host service that references Track from types.go + serviceSrc := `package host + +import "context" + +// MatcherService matches tracks. +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchTrack finds a matching track. + //nd:hostfunc + MatchTrack(ctx context.Context, t Track) (matched bool, err error) +} +` + err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(typesSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(serviceSrc), 0600) + Expect(err).NotTo(HaveOccurred()) + + services, err := ParseDirectory(tmpDir) + Expect(err).NotTo(HaveOccurred()) + Expect(services).To(HaveLen(1)) + + svc := services[0] + Expect(svc.Name).To(Equal("Matcher")) + Expect(svc.Methods).To(HaveLen(1)) + Expect(svc.Methods[0].Name).To(Equal("MatchTrack")) + + // Track must be resolved from the sibling file, not just the service file. + // Artist must also be transitively resolved (Track.Participants references it). + structNames := make([]string, len(svc.Structs)) + for i, s := range svc.Structs { + structNames[i] = s.Name + } + Expect(structNames).To(ConsistOf("Track", "Artist")) + }) + It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { fileA := `package host diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index 083f7577e..b1ac4dd36 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -7,6 +7,10 @@ import ( "encoding/json" extism "github.com/extism/go-sdk" +{{- if .Service.ImportsSharedTypes}} + + "github.com/navidrome/navidrome/plugins/types" +{{- end}} ) {{- /* Generate request/response types for all methods */ -}} diff --git a/plugins/host/matcher.go b/plugins/host/matcher.go new file mode 100644 index 000000000..678d3bfaa --- /dev/null +++ b/plugins/host/matcher.go @@ -0,0 +1,27 @@ +package host + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + // Username runs the match as that user (case-insensitive): their favourites and + // ratings inform tiebreaking, and the returned tracks carry their annotations. + Username string `json:"username,omitempty"` +} + +// MatcherService resolves externally-obtained songs to local library tracks, +// reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). +// +//nd:hostservice name=Matcher permission=matcher +type MatcherService interface { + // MatchSongs resolves each input song to its best-matching library track. + // It returns one entry per input song, in the same order as the input; the + // entry for an input song that had no match is empty (absent). Results are + // limited to the libraries the plugin (and the scoped user, if any) can access. + //nd:hostfunc + MatchSongs(ctx context.Context, songs []types.SongRef, opts MatchOptions) (results []*types.Track, err error) +} diff --git a/plugins/host/matcher_gen.go b/plugins/host/matcher_gen.go new file mode 100644 index 000000000..1bcd0dc3b --- /dev/null +++ b/plugins/host/matcher_gen.go @@ -0,0 +1,91 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" + + "github.com/navidrome/navidrome/plugins/types" +) + +// MatcherMatchSongsRequest is the request type for Matcher.MatchSongs. +type MatcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +// MatcherMatchSongsResponse is the response type for Matcher.MatchSongs. +type MatcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterMatcherHostFunctions registers Matcher service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterMatcherHostFunctions(service MatcherService) []extism.HostFunction { + return []extism.HostFunction{ + newMatcherMatchSongsHostFunction(service), + } +} + +func newMatcherMatchSongsHostFunction(service MatcherService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "matcher_matchsongs", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + matcherWriteError(p, stack, err) + return + } + var req MatcherMatchSongsRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + matcherWriteError(p, stack, err) + return + } + + // Call the service method + results, svcErr := service.MatchSongs(ctx, req.Songs, req.Opts) + if svcErr != nil { + matcherWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := MatcherMatchSongsResponse{ + Results: results, + } + matcherWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// matcherWriteResponse writes a JSON response to plugin memory. +func matcherWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + matcherWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// matcherWriteError writes an error response to plugin memory. +func matcherWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_matcher.go b/plugins/host_matcher.go new file mode 100644 index 000000000..9b65e7933 --- /dev/null +++ b/plugins/host_matcher.go @@ -0,0 +1,194 @@ +package plugins + +import ( + "cmp" + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/utils/slice" +) + +type matcherServiceImpl struct { + ds model.DataStore + hasFilesystemPerm bool + users userAccess + libs libraryAccess +} + +func newMatcherService(ds model.DataStore, hasFilesystemPerm bool, users userAccess, libs libraryAccess) host.MatcherService { + return &matcherServiceImpl{ + ds: ds, + hasFilesystemPerm: hasFilesystemPerm, + users: users, + libs: libs, + } +} + +func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongRef, opts host.MatchOptions) ([]*types.Track, error) { + results := make([]*types.Track, len(songs)) + if len(songs) == 0 { + return results, nil + } + + // Fail closed when the plugin has no library scope, rather than matching nothing. + if !s.libs.configured() { + return nil, fmt.Errorf("matcher: no libraries configured for this plugin") + } + + // Set the user context explicitly so the match never inherits the request user + // of whatever invoked the plugin: a username scopes to that user (loading their + // annotations and library access), and an unscoped match runs as admin so only + // the plugin's own library scope constrains the results. + scoped := opts.Username != "" + if scoped { + usr, err := s.users.resolve(ctx, s.ds, opts.Username) + if err != nil { + return nil, fmt.Errorf("matcher: %w", err) + } + ctx = request.WithUser(ctx, *usr) + } else { + ctx = adminContext(ctx) + } + + agentSongs := slice.Map(songs, songRefToAgentSong) + + matched, err := matcher.New(s.ds).MatchSongsIndexed(ctx, agentSongs) + if err != nil { + return nil, err + } + for i, mf := range matched { + // Drop tracks outside the plugin's library scope, leaving that index unmatched. + if !s.libs.contains(mf.LibraryID) { + continue + } + results[i] = s.toTrack(&mf, scoped) + } + return results, nil +} + +// toTrack projects a MediaFile into the public Track DTO. Path needs filesystem +// permission; per-user annotations are only set for a scoped match. +func (s *matcherServiceImpl) toTrack(mf *model.MediaFile, scoped bool) *types.Track { + t := &types.Track{ + ID: mf.ID, + LibraryID: int32(mf.LibraryID), + LibraryName: mf.LibraryName, + Missing: mf.Missing, + Title: mf.Title, + Album: mf.Album, + Artist: mf.Artist, + AlbumArtist: mf.AlbumArtist, + AlbumID: mf.AlbumID, + SortTitle: mf.SortTitle, + SortAlbumName: mf.SortAlbumName, + SortArtistName: mf.SortArtistName, + TrackNumber: int32(mf.TrackNumber), + DiscNumber: int32(mf.DiscNumber), + DiscSubtitle: mf.DiscSubtitle, + Year: int32(mf.Year), + Date: mf.Date, + OriginalYear: int32(mf.OriginalYear), + OriginalDate: mf.OriginalDate, + ReleaseYear: int32(mf.ReleaseYear), + ReleaseDate: mf.ReleaseDate, + Size: mf.Size, + Suffix: mf.Suffix, + Duration: float64(mf.Duration), + BitRate: int32(mf.BitRate), + SampleRate: int32(mf.SampleRate), + BitDepth: ptrInt32(mf.BitDepth), + Channels: int32(mf.Channels), + Codec: mf.Codec, + Comment: mf.Comment, + BPM: ptrInt32(mf.BPM), + ExplicitStatus: mf.ExplicitStatus, + CatalogNum: mf.CatalogNum, + Compilation: mf.Compilation, + HasCoverArt: mf.HasCoverArt, + MbzRecordingID: mf.MbzRecordingID, + MbzReleaseTrackID: mf.MbzReleaseTrackID, + MbzAlbumID: mf.MbzAlbumID, + MbzReleaseGroupID: mf.MbzReleaseGroupID, + MbzAlbumType: mf.MbzAlbumType, + MbzAlbumComment: mf.MbzAlbumComment, + RGAlbumGain: mf.RGAlbumGain, + RGAlbumPeak: mf.RGAlbumPeak, + RGTrackGain: mf.RGTrackGain, + RGTrackPeak: mf.RGTrackPeak, + AverageRating: mf.AverageRating, // aggregate, not user-scoped + BirthTime: unixOrZero(mf.BirthTime), + CreatedAt: unixOrZero(mf.CreatedAt), + UpdatedAt: unixOrZero(mf.UpdatedAt), + } + if s.hasFilesystemPerm { + t.Path = mf.Path + } + if len(mf.Genres) > 0 { + t.Genres = slice.Map(mf.Genres, func(g model.Genre) string { return g.Name }) + } + if len(mf.Tags) > 0 { + t.Tags = make(map[string][]string, len(mf.Tags)) + for name, values := range mf.Tags { + t.Tags[string(name)] = values + } + } + if len(mf.Participants) > 0 { + // Flatten the role→artists map into a role-tagged list, in stable role order. + roles := slices.SortedFunc(maps.Keys(mf.Participants), func(a, b model.Role) int { + return cmp.Compare(a.String(), b.String()) + }) + for _, role := range roles { + for _, p := range mf.Participants[role] { + t.Participants = append(t.Participants, types.ArtistRef{ + ID: p.ID, + Name: p.Name, + MBID: p.MbzArtistID, + SortName: p.SortArtistName, + Role: role.String(), + SubRole: p.SubRole, + }) + } + } + } + if scoped { + t.Starred = mf.Starred + t.StarredAt = unixPtr(mf.StarredAt) + t.Rating = int32(mf.Rating) + t.PlayCount = mf.PlayCount + t.PlayDate = unixPtr(mf.PlayDate) + } + return t +} + +func unixOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +// unixPtr maps a nullable time to Unix seconds, keeping nil distinct from the epoch. +func unixPtr(t *time.Time) *int64 { + if t == nil || t.IsZero() { + return nil + } + return new(t.Unix()) +} + +// ptrInt32 narrows a nullable *int to *int32, keeping nil distinct from a real 0. +func ptrInt32(p *int) *int32 { + if p == nil { + return nil + } + return new(int32(*p)) +} + +var _ host.MatcherService = (*matcherServiceImpl)(nil) diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go new file mode 100644 index 000000000..9d44ee581 --- /dev/null +++ b/plugins/host_matcher_test.go @@ -0,0 +1,490 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/plugins/types" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MatcherService", Ordered, func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + // newConverter returns the service as its concrete type so converter unit + // tests can call toTrack directly with a chosen filesystem-permission flag. + newConverter := func(hasFilesystemPerm bool) *matcherServiceImpl { + return newMatcherService(nil, hasFilesystemPerm, newUserAccess(nil, true), newLibraryAccess(nil, true)).(*matcherServiceImpl) + } + + Describe("toTrack", func() { + It("projects a MediaFile into a public Track", func() { + bitDepth := 24 + bpm := 128 + rgGain := -7.5 + created := time.Unix(1700000000, 0) + updated := time.Unix(1700000500, 0) + birth := time.Unix(1699999000, 0) + + mf := &model.MediaFile{ + ID: "mf-1", + LibraryID: 3, + LibraryName: "Main", + Path: "/music/song.flac", + Title: "My Song", + Album: "My Album", + Artist: "My Artist", + AlbumArtist: "My Artist", + AlbumID: "al-1", + SortTitle: "my song", + TrackNumber: 4, + DiscNumber: 1, + Year: 2020, + Size: 1234, + Suffix: "flac", + Duration: 210.5, + BitRate: 1000, + SampleRate: 44100, + BitDepth: &bitDepth, + Channels: 2, + Codec: "flac", + Genre: "Rock", + BPM: &bpm, + ExplicitStatus: "c", + Compilation: true, + HasCoverArt: true, + MbzRecordingID: "rec-1", + RGTrackGain: &rgGain, + CreatedAt: created, + UpdatedAt: updated, + BirthTime: birth, + Genres: model.Genres{{Name: "Rock"}, {Name: "Pop"}}, + Tags: model.Tags{model.TagName("isrc"): []string{"US-XXX-00"}}, + } + mf.AverageRating = 4.2 + mf.Participants = model.Participants{} + mf.Participants.Add(model.RoleArtist, model.Artist{ + ID: "ar-1", Name: "My Artist", SortArtistName: "artist, my", MbzArtistID: "mbz-ar-1", + }) + mf.Participants.AddWithSubRole(model.RolePerformer, "violin", model.Artist{ + ID: "ar-2", Name: "A Fiddler", + }) + + track := newConverter(true).toTrack(mf, false) + + Expect(track.ID).To(Equal("mf-1")) + Expect(track.LibraryID).To(Equal(int32(3))) + Expect(track.LibraryName).To(Equal("Main")) + Expect(track.Path).To(Equal("/music/song.flac")) + Expect(track.Title).To(Equal("My Song")) + Expect(track.Duration).To(Equal(210.5)) + Expect(track.BitDepth).To(HaveValue(Equal(int32(24)))) + Expect(track.BPM).To(HaveValue(Equal(int32(128)))) + Expect(track.RGTrackGain).To(HaveValue(Equal(-7.5))) + Expect(track.Compilation).To(BeTrue()) + Expect(track.MbzRecordingID).To(Equal("rec-1")) + Expect(track.Genres).To(Equal([]string{"Rock", "Pop"})) + Expect(track.CreatedAt).To(Equal(int64(1700000000))) + Expect(track.UpdatedAt).To(Equal(int64(1700000500))) + Expect(track.BirthTime).To(Equal(int64(1699999000))) + Expect(track.Tags).To(HaveKeyWithValue("isrc", []string{"US-XXX-00"})) + // Flat, role-tagged, role-sorted. + Expect(track.Participants).To(HaveLen(2)) + Expect(track.Participants[0]).To(Equal(types.ArtistRef{ + ID: "ar-1", Name: "My Artist", SortName: "artist, my", MBID: "mbz-ar-1", Role: "artist", + })) + Expect(track.Participants[1]).To(Equal(types.ArtistRef{ + ID: "ar-2", Name: "A Fiddler", Role: "performer", SubRole: "violin", + })) + // AverageRating is an aggregate, exposed even though the match is unscoped. + Expect(track.AverageRating).To(Equal(4.2)) + }) + + It("leaves nil-able numeric fields nil when absent", func() { + mf := &model.MediaFile{ID: "mf-2", Title: "No Optionals"} + track := newConverter(true).toTrack(mf, false) + Expect(track.BitDepth).To(BeNil()) + Expect(track.BPM).To(BeNil()) + Expect(track.RGAlbumGain).To(BeNil()) + Expect(track.RGAlbumPeak).To(BeNil()) + Expect(track.RGTrackGain).To(BeNil()) + Expect(track.RGTrackPeak).To(BeNil()) + }) + + It("preserves a real 0 ReplayGain value as non-nil", func() { + zero := 0.0 + mf := &model.MediaFile{ID: "mf-3", Title: "Zero RG", RGTrackGain: &zero} + track := newConverter(true).toTrack(mf, false) + Expect(track.RGTrackGain).To(HaveValue(Equal(0.0))) + Expect(track.RGAlbumGain).To(BeNil()) + }) + + It("exposes Path only when the plugin has filesystem permission", func() { + mf := &model.MediaFile{ID: "mf-4", Title: "With Path", Path: "/music/x.flac"} + Expect(newConverter(true).toTrack(mf, false).Path).To(Equal("/music/x.flac")) + Expect(newConverter(false).toTrack(mf, false).Path).To(BeEmpty()) + }) + }) + + Describe("MatchSongs", func() { + // The mock MediaFileRepo returns stored files (with annotations) verbatim, + // ignoring QueryOptions and the context user. These tests therefore cover the + // adapter's gating/access logic, not the SQL per-user join (a persistence-layer + // concern). + + // allowAll returns a service permitted to match as any user across all + // libraries. + allowAll := func(ds model.DataStore) host.MatcherService { + return newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + } + + It("returns one entry per input song in order, with nil for no-match", func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + // First (ID) phase returns the match for input song 0 only. + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-100", Title: "Hit", Artist: "Band"}, + }) + ds := &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), []types.SongRef{ + {ID: "mf-100", Name: "Hit", Artist: "Band"}, + {ID: "missing-id", Name: "Ghost", Artist: "Nobody"}, + }, host.MatchOptions{}) + + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-100")) + Expect(results[1]).To(BeNil()) + }) + + It("returns an empty slice for empty input", func() { + ds := &tests.MockDataStore{MockedMediaFile: tests.CreateMockMediaFileRepo()} + results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), nil, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + Context("with a scoped user", func() { + var ds *tests.MockDataStore + var userRepo *tests.MockedUserRepo + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mf := model.MediaFile{ID: "mf-1", Title: "Hit", Artist: "Band", LibraryID: 1} + mf.Starred = true + mf.Rating = 5 + mediaFileRepo.SetData(model.MediaFiles{mf}) + + userRepo = tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo, MockedUser: userRepo} + }) + + input := []types.SongRef{{ID: "mf-1", Name: "Hit", Artist: "Band"}} + + It("does not expose annotations when no username is given", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeFalse()) + Expect(results[0].Rating).To(BeZero()) + }) + + It("exposes the user's annotations when an allowed username is given", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-alice"}, false), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].Starred).To(BeTrue()) + Expect(results[0].Rating).To(Equal(int32(5))) + }) + + It("allows any username when allUsers is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0].Starred).To(BeTrue()) + }) + + It("returns an error for an unknown username", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + Expect(err).To(MatchError(ContainSubstring("not found"))) + }) + + It("surfaces a backend error rather than masking it as not-found", func() { + userRepo.Error = errors.New("db is locked") + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("db is locked"))) + Expect(err.Error()).ToNot(ContainSubstring("not found")) + }) + + It("returns an error for a username the plugin is not allowed to use", func() { + svc := newMatcherService(ds, false, newUserAccess([]string{"u-bob"}, false), newLibraryAccess(nil, true)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + Expect(err).To(MatchError(ContainSubstring("not allowed"))) + }) + + It("rejects a username with the same error whether it exists, when the plugin has no user scope", func() { + // A plugin with no user scope (the only state a matcher-only plugin can + // be in) must not leak whether a username exists via the error text. + svc := newMatcherService(ds, false, newUserAccess(nil, false), newLibraryAccess(nil, true)) + + _, errExisting := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) + _, errMissing := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) + + Expect(errExisting).To(HaveOccurred()) + Expect(errExisting.Error()).To(Equal(errMissing.Error())) + Expect(errExisting.Error()).ToNot(ContainSubstring("not found")) + Expect(errExisting.Error()).To(ContainSubstring("not authorized to scope by user")) + }) + + It("does not inherit the caller's request user for an unscoped match", func() { + // The plugin may be invoked while handling another user's request; an + // unscoped match must run as admin, not as that inherited user. + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.IsAdmin).To(BeTrue()) + Expect(usr.ID).ToNot(Equal("u-caller")) + }) + + It("uses the requested user, overriding an inherited caller user", func() { + capturing := &ctxCapturingDataStore{MockDataStore: ds} + svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + + callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) + _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{Username: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + usr, ok := request.UserFrom(capturing.lastMediaFileCtx) + Expect(ok).To(BeTrue()) + Expect(usr.ID).To(Equal("u-alice")) + }) + }) + + Context("with plugin library access", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + mediaFileRepo := tests.CreateMockMediaFileRepo() + mediaFileRepo.SetData(model.MediaFiles{ + {ID: "mf-lib1", Title: "A", Artist: "Band", LibraryID: 1}, + {ID: "mf-lib2", Title: "B", Artist: "Band", LibraryID: 2}, + }) + ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo} + }) + + input := []types.SongRef{ + {ID: "mf-lib1", Name: "A", Artist: "Band"}, + {ID: "mf-lib2", Name: "B", Artist: "Band"}, + } + + It("drops matches from libraries the plugin cannot access", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess([]int{1}, false)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[0].ID).To(Equal("mf-lib1")) + Expect(results[1]).To(BeNil()) // library 2 not permitted + }) + + It("keeps all matches when allLibraries is set", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) + results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(results[0]).ToNot(BeNil()) + Expect(results[1]).ToNot(BeNil()) + }) + + It("errors when the plugin has no library scope configured", func() { + svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, false)) + _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) + Expect(err).To(MatchError(ContainSubstring("no libraries configured"))) + }) + }) + }) + + // Artist precedence for songRefToAgentSong is covered in metadata_agent_test.go; + // here we cover the duration normalization the matcher path relies on. + Describe("songRefToAgentSong duration", func() { + It("prefers DurationMs over the deprecated seconds field", func() { + song := songRefToAgentSong(types.SongRef{DurationMs: 247333, Duration: 99}) + Expect(song.Duration).To(Equal(uint32(247333))) + }) + + It("falls back to the seconds field when DurationMs is zero", func() { + song := songRefToAgentSong(types.SongRef{Duration: 210.5}) + Expect(song.Duration).To(Equal(uint32(210500))) + }) + + It("clamps a negative seconds duration to zero instead of overflowing", func() { + song := songRefToAgentSong(types.SongRef{Duration: -1}) + Expect(song.Duration).To(BeZero()) + }) + }) +}) + +var _ = Describe("MatcherService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "matcher-integration-test-*") + Expect(err).ToNot(HaveOccurred()) + + srcPath := filepath.Join(testdataDir, "test-matcher"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-matcher"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + // AllLibraries: the matcher requires a library scope. + mockPluginRepo.SetData(model.Plugins{{ + ID: "test-matcher", + Path: destPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, + AllLibraries: true, + }}) + + mediaFileRepo := tests.CreateMockMediaFileRepo() + hit := model.MediaFile{ID: "mf-hit", Title: "Hit", Artist: "Band"} + hit.Starred = true + mediaFileRepo.SetData(model.MediaFiles{hit}) + + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) + + dataStore := &tests.MockDataStore{ + MockedPlugin: mockPluginRepo, + MockedMediaFile: mediaFileRepo, + MockedUser: userRepo, + } + + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + subsonicRouter: http.NotFoundHandler(), + } + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + It("loads the plugin with the matcher permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["test-matcher"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Matcher).ToNot(BeNil()) + }) + + It("matches songs through the host boundary, preserving order and nils", func() { + ctx := GinkgoT().Context() + manager.mu.RLock() + p := manager.plugins["test-matcher"] + manager.mu.RUnlock() + + instance, err := p.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(ctx) + + type tIn struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` + } + type tOut struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` + } + + call := func(in tIn) tOut { + inputBytes, err := json.Marshal(in) + Expect(err).ToNot(HaveOccurred()) + _, outputBytes, err := instance.Call("nd_test_matcher", inputBytes) + Expect(err).ToNot(HaveOccurred()) + var out tOut + Expect(json.Unmarshal(outputBytes, &out)).To(Succeed()) + Expect(out.Error).To(BeNil()) + return out + } + + songs := []types.SongRef{ + {ID: "mf-hit", Name: "Hit", Artist: "Band"}, + {ID: "nope", Name: "Ghost", Artist: "Nobody"}, + } + + By("matching without a user, preserving order and nils") + out := call(tIn{Songs: songs}) + Expect(out.MatchedIDs).To(HaveLen(2)) + Expect(out.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(out.MatchedIDs[1]).To(BeEmpty()) + Expect(out.Starred[0]).To(BeFalse()) // no user scope → no annotations + + By("matching as a user, exposing that user's annotations across the boundary") + scoped := call(tIn{Songs: songs, Username: "alice"}) + Expect(scoped.MatchedIDs[0]).To(Equal("mf-hit")) + Expect(scoped.Starred[0]).To(BeTrue()) + }) +}) + +// ctxCapturingDataStore records the context passed to MediaFile so tests can assert +// which user the matcher resolved before querying the library. +type ctxCapturingDataStore struct { + *tests.MockDataStore + lastMediaFileCtx context.Context +} + +func (d *ctxCapturingDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + d.lastMediaFileCtx = ctx + return d.MockDataStore.MediaFile(ctx) +} diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index 01a33c039..dba58d795 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1" // URL Format: Only the path and query parameters are used - host/protocol are ignored. // Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). type subsonicAPIServiceImpl struct { - pluginID string - router SubsonicRouter - ds model.DataStore - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - userIDMap map[string]struct{} + pluginID string + router SubsonicRouter + ds model.DataStore + users userAccess // users this plugin may act as (from DB configuration) } // newSubsonicAPIService creates a new SubsonicAPIService for a plugin. -func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService { - userIDMap := make(map[string]struct{}) - for _, id := range allowedUserIDs { - userIDMap[id] = struct{}{} - } +func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService { return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - allowedUserIDs: allowedUserIDs, - allUsers: allUsers, - userIDMap: userIDMap, + pluginID: pluginID, + router: router, + ds: ds, + users: users, } } @@ -136,12 +128,12 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { // If allUsers is true, allow any user - if s.allUsers { + if s.users.allUsers { return nil } // Must have at least one allowed user ID configured - if len(s.allowedUserIDs) == 0 { + if len(s.users.userIDSet) == 0 { return fmt.Errorf("no users configured for plugin %s", s.pluginID) } @@ -155,7 +147,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Check if the user's ID is in the allowed list - if _, ok := s.userIDMap[usr.ID]; !ok { + if !s.users.allows(usr.ID) { return fmt.Errorf("user %s is not authorized for this plugin", username) } diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 6f7ff4dd3..c3d9ffe8a 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -267,7 +267,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with specific user IDs allowed", func() { It("blocks users not in the allowed list", func() { // allowedUserIDs contains "user2", but testuser is "user1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -277,7 +277,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows users in the allowed list", func() { // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") @@ -287,7 +287,7 @@ var _ = Describe("SubsonicAPIService", func() { It("blocks admin users when not in allowed list", func() { // allowedUserIDs only contains "user1" (testuser), not "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") @@ -297,7 +297,7 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when in allowed list", func() { // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -308,7 +308,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with allUsers=true", func() { It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") @@ -317,7 +317,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") @@ -328,7 +328,7 @@ var _ = Describe("SubsonicAPIService", func() { Context("with no users configured", func() { It("returns error when no users are configured", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -337,7 +337,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") @@ -349,7 +349,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("URL Handling", func() { It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") @@ -358,7 +358,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") @@ -367,7 +367,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") @@ -380,7 +380,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("CallRaw", func() { It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -390,7 +390,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -402,7 +402,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") @@ -411,7 +411,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") @@ -420,7 +420,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") @@ -429,7 +429,7 @@ var _ = Describe("SubsonicAPIService", func() { }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") @@ -440,7 +440,7 @@ var _ = Describe("SubsonicAPIService", func() { Describe("Router Availability", func() { It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 115c6c2b0..604fba3a7 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -52,7 +52,7 @@ var hostServices = []hostServiceEntry{ name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { - service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) + service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) return host.RegisterSubsonicAPIHostFunctions(service), nil }, }, @@ -119,6 +119,19 @@ var hostServices = []hostServiceEntry{ return host.RegisterUsersHostFunctions(service), nil }, }, + { + name: "Matcher", + hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem + service := newMatcherService( + ctx.manager.ds, hasFilesystemPerm, + newUserAccess(ctx.allowedUsers, ctx.allUsers), + newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries), + ) + return host.RegisterMatcherHostFunctions(service), nil + }, + }, { name: "HTTP", hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 1d4a8c301..f0c7c56d5 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -4,9 +4,11 @@ import ( "context" "crypto/rand" "errors" + "fmt" "io" extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/model" "github.com/tetratelabs/wazero" ) @@ -75,3 +77,58 @@ func (a libraryAccess) contains(libID int) bool { _, ok := a.libraryIDSet[libID] return ok } + +// configured reports whether the plugin has any library scope (all, or specific). +func (a libraryAccess) configured() bool { + return a.allLibraries || len(a.libraryIDSet) > 0 +} + +// userAccess captures the set of users a plugin is permitted to act as, +// precomputed at load time for O(1) lookup. +type userAccess struct { + allUsers bool + userIDSet map[string]struct{} +} + +func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess { + set := make(map[string]struct{}, len(allowedUserIDs)) + for _, id := range allowedUserIDs { + set[id] = struct{}{} + } + return userAccess{allUsers: allUsers, userIDSet: set} +} + +// allows reports whether the plugin may act as the given user ID. +func (a userAccess) allows(userID string) bool { + if a.allUsers { + return true + } + _, ok := a.userIDSet[userID] + return ok +} + +// resolve looks up a user by username and authorizes it against this access set, +// distinguishing an absent user from a backend failure. +// +// When the plugin has no user scope at all, it rejects before the lookup with a +// single fixed error, so a caller cannot tell a real account from a missing one by +// the error text (username enumeration). +func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) { + if !a.allUsers && len(a.userIDSet) == 0 { + return nil, fmt.Errorf("plugin is not authorized to scope by user") + } + usr, err := ds.User(ctx).FindByUsername(username) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, fmt.Errorf("user %q not found", username) + } + return nil, fmt.Errorf("looking up user %q: %w", username, err) + } + if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil) + return nil, fmt.Errorf("user %q not found", username) + } + if !a.allows(usr.ID) { + return nil, fmt.Errorf("plugin is not allowed to act as user %q", username) + } + return usr, nil +} diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index c15a3bf3d..29e5d1fc7 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -113,6 +113,9 @@ }, "taskqueue": { "$ref": "#/$defs/TaskQueuePermission" + }, + "matcher": { + "$ref": "#/$defs/MatcherPermission" } } }, @@ -254,6 +257,17 @@ "description": "Explanation for why users access is needed" } } + }, + "MatcherPermission": { + "type": "object", + "description": "Matcher service permissions for resolving external songs to local library tracks", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why matcher access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 990272855..6bd0e8049 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -59,6 +59,14 @@ func (m *Manifest) Validate() error { } } + // Matcher returns library content, so it requires the library permission (which + // is what exposes a library scope for configuration). + if m.Permissions != nil && m.Permissions.Matcher != nil { + if m.Permissions.Library == nil { + return fmt.Errorf("'matcher' permission requires 'library' permission to be declared") + } + } + // Validate config schema if present if m.Config != nil && m.Config.Schema != nil { if err := validateConfigSchema(m.Config.Schema); err != nil { diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index efe93e05f..3599eafc4 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -158,6 +158,12 @@ func (j *Manifest) UnmarshalJSON(value []byte) error { return nil } +// Matcher service permissions for resolving external songs to local library tracks +type MatcherPermission struct { + // Explanation for why matcher access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Permissions required by the plugin type Permissions struct { // Artwork corresponds to the JSON schema field "artwork". @@ -175,6 +181,9 @@ type Permissions struct { // Library corresponds to the JSON schema field "library". Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"` + // Matcher corresponds to the JSON schema field "matcher". + Matcher *MatcherPermission `json:"matcher,omitempty" yaml:"matcher,omitempty" mapstructure:"matcher,omitempty"` + // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index cae043fc7..2a8b0dcfa 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -261,6 +261,37 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("subsonicapi")) }) + It("validates manifest with matcher and library permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + Library: &LibraryPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when matcher without library permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Matcher: &MatcherPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("matcher")) + Expect(err.Error()).To(ContainSubstring("library")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index fa3e0082e..e5d6d43fc 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -250,7 +250,7 @@ func songRefToAgentSong(s types.SongRef) agents.Song { Artists: artists, Album: s.Album, AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), + Duration: s.DurationInMs(), } } diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 5781a04c1..ff2c2a07f 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -41,6 +41,7 @@ The following host services are available: - HTTP: provides outbound HTTP request capabilities for plugins. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. + - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_matcher.go b/plugins/pdk/go/host/nd_host_matcher.go new file mode 100644 index 000000000..b32ac6f9d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher.go @@ -0,0 +1,77 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/types" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// matcher_matchsongs is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user matcher_matchsongs +func matcher_matchsongs(uint64) uint64 + +type matcherMatchSongsRequest struct { + Songs []types.SongRef `json:"songs"` + Opts MatchOptions `json:"opts"` +} + +type matcherMatchSongsResponse struct { + Results []*types.Track `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +// MatcherMatchSongs calls the matcher_matchsongs host function. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + // Marshal request to JSON + req := matcherMatchSongsRequest{ + Songs: songs, + Opts: opts, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := matcher_matchsongs(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response matcherMatchSongsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Results, nil +} diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go new file mode 100644 index 000000000..07b34bb82 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_matcher_stub.go @@ -0,0 +1,44 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/types" + "github.com/stretchr/testify/mock" +) + +// MatchOptions represents the MatchOptions data structure. +// MatchOptions carries optional parameters for a match request. +type MatchOptions struct { + Username string `json:"username"` +} + +// mockMatcherService is the mock implementation for testing. +type mockMatcherService struct { + mock.Mock +} + +// MatcherMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.MatcherMock.On("MethodName", args...).Return(values...) +var MatcherMock = &mockMatcherService{} + +// MatchSongs is the mock method for MatcherMatchSongs. +func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + args := m.Called(songs, opts) + return args.Get(0).([]*types.Track), args.Error(1) +} + +// MatcherMatchSongs delegates to the mock instance. +// MatchSongs resolves each input song to its best-matching library track. +// It returns one entry per input song, in the same order as the input; the +// entry for an input song that had no match is empty (absent). Results are +// limited to the libraries the plugin (and the scoped user, if any) can access. +func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { + return MatcherMock.MatchSongs(songs, opts) +} diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go index 856be90e1..4d4cdedd2 100644 --- a/plugins/pdk/go/types/types.go +++ b/plugins/pdk/go/types/types.go @@ -8,7 +8,8 @@ package types // ArtistRef is the minimal information a plugin returns for Navidrome to match an // artist against the library. It is a reference, not a full artist entity: it -// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never // descriptive data such as biographies or images. type ArtistRef struct { // ID is the internal Navidrome artist ID (if known). @@ -17,6 +18,12 @@ type ArtistRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the artist. MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` } // SongRef is the minimal information exchanged between a plugin and Navidrome to @@ -34,8 +41,12 @@ type SongRef struct { // ISRC is the International Standard Recording Code for the song. ISRC string `json:"isrc,omitempty"` // Artist is the artist name. + // + // Deprecated: use Artists. Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. ArtistMBID string `json:"artistMbid,omitempty"` // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. Artists []ArtistRef `json:"artists,omitempty"` @@ -44,5 +55,94 @@ type SongRef struct { // AlbumMBID is the MusicBrainz release ID. AlbumMBID string `json:"albumMbid,omitempty"` // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` } diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3a31bc489..cc1fdc190 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -38,6 +38,7 @@ //! - [`http`] - provides outbound HTTP request capabilities for plugins. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. +//! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -86,6 +87,13 @@ pub mod library { pub use super::nd_host_library::*; } +#[doc(hidden)] +mod nd_host_matcher; +/// resolves externally-obtained songs to local library tracks, +pub mod matcher { + pub use super::nd_host_matcher::*; +} + #[doc(hidden)] mod nd_host_scheduler; /// provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs new file mode 100644 index 000000000..be2819257 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs @@ -0,0 +1,65 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Matcher host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// MatchOptions carries optional parameters for a match request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchOptions { + #[serde(default)] + pub username: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsRequest { + songs: Vec, + opts: MatchOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MatcherMatchSongsResponse { + #[serde(default)] + results: Vec>, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn matcher_matchsongs(input: Json) -> Json; +} + +/// MatchSongs resolves each input song to its best-matching library track. +/// It returns one entry per input song, in the same order as the input; the +/// entry for an input song that had no match is empty (absent). Results are +/// limited to the libraries the plugin (and the scoped user, if any) can access. +/// +/// # Arguments +/// * `songs` - Vec parameter. +/// * `opts` - MatchOptions parameter. +/// +/// # Returns +/// The results value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn match_songs(songs: Vec, opts: MatchOptions) -> Result>, Error> { + let response = unsafe { + matcher_matchsongs(Json(MatcherMatchSongsRequest { + songs: songs, + opts: opts, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.results) +} diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs index 9b40c4d56..fa1a87048 100644 --- a/plugins/pdk/rust/nd-pdk-types/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-types/src/lib.rs @@ -3,6 +3,7 @@ //! Navidrome shared plugin data types. use serde::{Deserialize, Serialize}; +use std::collections::HashMap; // Helper functions for skip_serializing_if with numeric types #[allow(dead_code)] @@ -19,7 +20,8 @@ fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } /// ArtistRef is the minimal information a plugin returns for Navidrome to match an /// artist against the library. It is a reference, not a full artist entity: it -/// carries only matching keys (name and optional internal/MusicBrainz IDs), never +/// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +/// few projection fields used when describing a track's participants, never /// descriptive data such as biographies or images. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -33,6 +35,15 @@ pub struct ArtistRef { /// MBID is the MusicBrainz ID for the artist. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbid: String, + /// SortName is the artist name used for sorting (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_name: String, + /// Role is the participation category (e.g. "artist", "composer", "performer"). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub role: String, + /// SubRole is a specialization within Role (e.g. the instrument for a performer). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sub_role: String, } /// SongRef is the minimal information exchanged between a plugin and Navidrome to /// match a song. It is used both as input (a song Navidrome already has) and as @@ -55,9 +66,13 @@ pub struct SongRef { #[serde(default, skip_serializing_if = "String::is_empty")] pub isrc: String, /// Artist is the artist name. + /// + /// Deprecated: use Artists. #[serde(default, skip_serializing_if = "String::is_empty")] pub artist: String, /// ArtistMBID is the MusicBrainz artist ID. + /// + /// Deprecated: use Artists. #[serde(default, skip_serializing_if = "String::is_empty")] pub artist_mbid: String, /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. @@ -70,6 +85,155 @@ pub struct SongRef { #[serde(default, skip_serializing_if = "String::is_empty")] pub album_mbid: String, /// Duration is the song duration in seconds. + /// + /// Deprecated: use DurationMs, which carries millisecond precision. When + /// DurationMs is non-zero it takes precedence; Duration is kept only for + /// backwards compatibility with plugins that still send seconds. #[serde(default, skip_serializing_if = "is_zero_f32")] pub duration: f32, + /// DurationMs is the song duration in milliseconds. It supersedes Duration + /// when non-zero. + #[serde(default, skip_serializing_if = "is_zero_u32")] + pub duration_ms: u32, +} +/// Track is a stable, public projection of a library media file for plugin consumption. +/// It is a sane subset of the internal model.MediaFile, intended for reuse across host +/// services and capabilities. Timestamps are Unix epoch seconds. +/// +/// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +/// Track is a concrete library entity: it identifies a specific media file that exists +/// (or once existed) in the library and exposes its full descriptive metadata. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Track { + /// Identity & location + #[serde(default)] + pub id: String, + #[serde(default)] + pub library_id: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub library_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub path: String, + #[serde(default)] + pub missing: bool, + /// Core metadata + #[serde(default)] + pub title: String, + #[serde(default)] + pub album: String, + #[serde(default)] + pub artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_artist: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_title: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_album_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub sort_artist_name: String, + /// Track / disc / dates + #[serde(default)] + pub track_number: i32, + #[serde(default)] + pub disc_number: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub disc_subtitle: String, + #[serde(default)] + pub year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub date: String, + #[serde(default)] + pub original_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub original_date: String, + #[serde(default)] + pub release_year: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub release_date: String, + /// Audio / file + #[serde(default)] + pub size: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub suffix: String, + #[serde(default)] + pub duration: f64, + #[serde(default)] + pub bit_rate: i32, + #[serde(default)] + pub sample_rate: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bit_depth: Option, + #[serde(default)] + pub channels: i32, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub codec: String, + /// Descriptive + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub genres: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub comment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bpm: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub explicit_status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub catalog_num: String, + #[serde(default)] + pub compilation: bool, + #[serde(default)] + pub has_cover_art: bool, + /// MusicBrainz + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_recording_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_track_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_release_group_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbz_album_comment: String, + /// ReplayGain — nil means no data; 0 is a valid measured value, so these + /// must stay pointers to distinguish "absent" from "0". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_gain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_album_peak: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_gain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rg_track_peak: Option, + /// Timestamps (Unix epoch seconds) + #[serde(default)] + pub birth_time: i64, + #[serde(default)] + pub created_at: i64, + #[serde(default)] + pub updated_at: i64, + /// AverageRating is the track's mean rating across all users (always set; 0 when unrated). + #[serde(default)] + pub average_rating: f64, + /// Per-user annotations, set only for a user-scoped match. Timestamps are Unix + /// seconds; a nil pointer means "no value". + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub starred: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub starred_at: Option, + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub rating: i32, + #[serde(default, skip_serializing_if = "is_zero_i64")] + pub play_count: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub play_date: Option, + /// Composite + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tags: std::collections::HashMap>, + /// Participants lists the track's artists across all roles, each tagged with its Role. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub participants: Vec, } diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index 2d48b28f0..a4f7d73f8 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -71,10 +71,10 @@ func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { ArtistMBID: mf.MbzArtistID, Album: mf.Album, AlbumMBID: mf.MbzAlbumID, - Duration: mf.Duration, } + ref.SetDuration(mf.Duration) for _, p := range mf.Participants[model.RoleArtist] { - ref.Artists = append(ref.Artists, capabilities.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID}) + ref.Artists = append(ref.Artists, types.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, SortName: p.SortArtistName, Role: model.RoleArtist.String()}) } if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { ref.ISRC = isrcs[0] diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index 52079732b..cb08b9acd 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -122,8 +122,8 @@ var _ = Describe("mediaFileToSongRef multi-artist", func() { }} ref := mediaFileToSongRef(mf) Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{ - {ID: "ar-drake", Name: "Drake", MBID: "m-drake"}, - {ID: "ar-future", Name: "Future", MBID: "m-future"}, + {ID: "ar-drake", Name: "Drake", MBID: "m-drake", Role: "artist"}, + {ID: "ar-future", Name: "Future", MBID: "m-future", Role: "artist"}, })) }) It("leaves Artists nil when the track has no role=artist participants", func() { diff --git a/plugins/testdata/test-matcher/go.mod b/plugins/testdata/test-matcher/go.mod new file mode 100644 index 000000000..7abbc93c6 --- /dev/null +++ b/plugins/testdata/test-matcher/go.mod @@ -0,0 +1,16 @@ +module test-matcher + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-matcher/go.sum b/plugins/testdata/test-matcher/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-matcher/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-matcher/main.go b/plugins/testdata/test-matcher/main.go new file mode 100644 index 000000000..80d650b7b --- /dev/null +++ b/plugins/testdata/test-matcher/main.go @@ -0,0 +1,56 @@ +// Test Matcher plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../test-matcher.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/types" +) + +// TestMatcherInput is the input for the nd_test_matcher callback. +type TestMatcherInput struct { + Songs []types.SongRef `json:"songs"` + Username string `json:"username,omitempty"` +} + +// TestMatcherOutput is the output from the nd_test_matcher callback. +// MatchedIDs and Starred are aligned to the input: an empty string at index i +// means no match; Starred[i] reflects the matched track's starred flag. +type TestMatcherOutput struct { + MatchedIDs []string `json:"matched_ids"` + Starred []bool `json:"starred"` + Error *string `json:"error,omitempty"` +} + +// nd_test_matcher forwards the input song list to the host matcher and returns matched track IDs. +// +//go:wasmexport nd_test_matcher +func ndTestMatcher() int32 { + var input TestMatcherInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + results, err := host.MatcherMatchSongs(input.Songs, host.MatchOptions{Username: input.Username}) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) + return 0 + } + + ids := make([]string, len(results)) + starred := make([]bool, len(results)) + for i, t := range results { + if t != nil { + ids[i] = t.ID + starred[i] = t.Starred + } + } + pdk.OutputJSON(TestMatcherOutput{MatchedIDs: ids, Starred: starred}) + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-matcher/manifest.json b/plugins/testdata/test-matcher/manifest.json new file mode 100644 index 000000000..26be5c577 --- /dev/null +++ b/plugins/testdata/test-matcher/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Matcher Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for Matcher integration testing", + "permissions": { + "matcher": { + "reason": "For testing matcher operations" + }, + "library": { + "reason": "Matching returns library tracks" + } + } +} diff --git a/plugins/types/track.go b/plugins/types/track.go new file mode 100644 index 000000000..9b63c0f26 --- /dev/null +++ b/plugins/types/track.go @@ -0,0 +1,93 @@ +package types + +// Track is a stable, public projection of a library media file for plugin consumption. +// It is a sane subset of the internal model.MediaFile, intended for reuse across host +// services and capabilities. Timestamps are Unix epoch seconds. +// +// Unlike SongRef, which is an abstract recording reference carrying only matching keys, +// Track is a concrete library entity: it identifies a specific media file that exists +// (or once existed) in the library and exposes its full descriptive metadata. +type Track struct { + // Identity & location + ID string `json:"id"` + LibraryID int32 `json:"libraryId"` + LibraryName string `json:"libraryName,omitempty"` + Path string `json:"path,omitempty"` + Missing bool `json:"missing"` + + // Core metadata + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumArtist string `json:"albumArtist,omitempty"` + AlbumID string `json:"albumId,omitempty"` + SortTitle string `json:"sortTitle,omitempty"` + SortAlbumName string `json:"sortAlbumName,omitempty"` + SortArtistName string `json:"sortArtistName,omitempty"` + + // Track / disc / dates + TrackNumber int32 `json:"trackNumber"` + DiscNumber int32 `json:"discNumber"` + DiscSubtitle string `json:"discSubtitle,omitempty"` + Year int32 `json:"year"` + Date string `json:"date,omitempty"` + OriginalYear int32 `json:"originalYear"` + OriginalDate string `json:"originalDate,omitempty"` + ReleaseYear int32 `json:"releaseYear"` + ReleaseDate string `json:"releaseDate,omitempty"` + + // Audio / file + Size int64 `json:"size"` + Suffix string `json:"suffix,omitempty"` + Duration float64 `json:"duration"` + BitRate int32 `json:"bitRate"` + SampleRate int32 `json:"sampleRate"` + BitDepth *int32 `json:"bitDepth,omitempty"` + Channels int32 `json:"channels"` + Codec string `json:"codec,omitempty"` + + // Descriptive + Genres []string `json:"genres,omitempty"` + Comment string `json:"comment,omitempty"` + BPM *int32 `json:"bpm,omitempty"` + ExplicitStatus string `json:"explicitStatus,omitempty"` + CatalogNum string `json:"catalogNum,omitempty"` + Compilation bool `json:"compilation"` + HasCoverArt bool `json:"hasCoverArt"` + + // MusicBrainz + MbzRecordingID string `json:"mbzRecordingId,omitempty"` + MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` + MbzAlbumType string `json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` + + // ReplayGain — nil means no data; 0 is a valid measured value, so these + // must stay pointers to distinguish "absent" from "0". + RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` + RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` + RGTrackGain *float64 `json:"rgTrackGain,omitempty"` + RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` + + // Timestamps (Unix epoch seconds) + BirthTime int64 `json:"birthTime"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + + // AverageRating is the track's mean rating across all users (always set; 0 when unrated). + AverageRating float64 `json:"averageRating"` + + // Per-user annotations, set only for a user-scoped match. Timestamps are Unix + // seconds; a nil pointer means "no value". + Starred bool `json:"starred,omitempty"` + StarredAt *int64 `json:"starredAt,omitempty"` + Rating int32 `json:"rating,omitempty"` + PlayCount int64 `json:"playCount,omitempty"` + PlayDate *int64 `json:"playDate,omitempty"` + + // Composite + Tags map[string][]string `json:"tags,omitempty"` + // Participants lists the track's artists across all roles, each tagged with its Role. + Participants []ArtistRef `json:"participants,omitempty"` +} diff --git a/plugins/types/types.go b/plugins/types/types.go index eaccf3469..71928a924 100644 --- a/plugins/types/types.go +++ b/plugins/types/types.go @@ -2,7 +2,8 @@ package types // ArtistRef is the minimal information a plugin returns for Navidrome to match an // artist against the library. It is a reference, not a full artist entity: it -// carries only matching keys (name and optional internal/MusicBrainz IDs), never +// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a +// few projection fields used when describing a track's participants, never // descriptive data such as biographies or images. type ArtistRef struct { // ID is the internal Navidrome artist ID (if known). @@ -11,6 +12,12 @@ type ArtistRef struct { Name string `json:"name"` // MBID is the MusicBrainz ID for the artist. MBID string `json:"mbid,omitempty"` + // SortName is the artist name used for sorting (if known). + SortName string `json:"sortName,omitempty"` + // Role is the participation category (e.g. "artist", "composer", "performer"). + Role string `json:"role,omitempty"` + // SubRole is a specialization within Role (e.g. the instrument for a performer). + SubRole string `json:"subRole,omitempty"` } // SongRef is the minimal information exchanged between a plugin and Navidrome to @@ -28,8 +35,12 @@ type SongRef struct { // ISRC is the International Standard Recording Code for the song. ISRC string `json:"isrc,omitempty"` // Artist is the artist name. + // + // Deprecated: use Artists. Artist string `json:"artist,omitempty"` // ArtistMBID is the MusicBrainz artist ID. + // + // Deprecated: use Artists. ArtistMBID string `json:"artistMbid,omitempty"` // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. Artists []ArtistRef `json:"artists,omitempty"` @@ -38,5 +49,39 @@ type SongRef struct { // AlbumMBID is the MusicBrainz release ID. AlbumMBID string `json:"albumMbid,omitempty"` // Duration is the song duration in seconds. + // + // Deprecated: use DurationMs, which carries millisecond precision. When + // DurationMs is non-zero it takes precedence; Duration is kept only for + // backwards compatibility with plugins that still send seconds. Duration float32 `json:"duration,omitempty"` + // DurationMs is the song duration in milliseconds. It supersedes Duration + // when non-zero. + DurationMs uint32 `json:"durationMs,omitempty"` +} + +// DurationInMs returns the song duration in milliseconds, preferring the +// millisecond-precision DurationMs and falling back to the deprecated +// seconds-based Duration. It returns 0 when neither is set, and clamps a +// negative seconds value to 0 to avoid an unsigned-conversion wraparound. +func (s SongRef) DurationInMs() uint32 { + if s.DurationMs != 0 { + return s.DurationMs + } + if s.Duration < 0 { + return 0 + } + return uint32(s.Duration * 1000) +} + +// SetDuration sets the song duration from a value in seconds, populating both the +// millisecond-precision DurationMs and the deprecated seconds-based Duration so +// that plugins reading either field see a consistent value. Use this when +// building a SongRef to send to a plugin. +func (s *SongRef) SetDuration(seconds float32) { + s.Duration = seconds + if seconds < 0 { + s.DurationMs = 0 + return + } + s.DurationMs = uint32(seconds * 1000) } diff --git a/plugins/types/types_suite_test.go b/plugins/types/types_suite_test.go new file mode 100644 index 000000000..9c058ec4f --- /dev/null +++ b/plugins/types/types_suite_test.go @@ -0,0 +1,17 @@ +package types_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTypes(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Types Suite") +} diff --git a/plugins/types/types_test.go b/plugins/types/types_test.go new file mode 100644 index 000000000..ae6d40a25 --- /dev/null +++ b/plugins/types/types_test.go @@ -0,0 +1,50 @@ +package types_test + +import ( + "github.com/navidrome/navidrome/plugins/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SongRef", func() { + Describe("DurationInMs", func() { + It("returns DurationMs when set", func() { + s := types.SongRef{DurationMs: 247333, Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247333))) + }) + + It("falls back to Duration (seconds) when DurationMs is zero", func() { + s := types.SongRef{Duration: 247.5} + Expect(s.DurationInMs()).To(Equal(uint32(247500))) + }) + + It("returns 0 when neither is set", func() { + Expect(types.SongRef{}.DurationInMs()).To(BeZero()) + }) + + It("clamps a negative seconds value to 0 instead of overflowing", func() { + Expect(types.SongRef{Duration: -1}.DurationInMs()).To(BeZero()) + }) + }) + + Describe("SetDuration", func() { + It("populates both DurationMs and the deprecated Duration from seconds", func() { + var s types.SongRef + s.SetDuration(247.333) + Expect(s.Duration).To(BeNumerically("~", 247.333, 0.001)) + Expect(s.DurationMs).To(Equal(uint32(247333))) + }) + + It("keeps DurationInMs consistent with what was set", func() { + var s types.SongRef + s.SetDuration(60) + Expect(s.DurationInMs()).To(Equal(uint32(60000))) + }) + + It("clamps a negative duration to a zero DurationMs", func() { + var s types.SongRef + s.SetDuration(-1) + Expect(s.DurationMs).To(BeZero()) + }) + }) +})