mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
feat(plugins): expose the song Matcher as a host service (#5643)
* feat(plugins): add public Track and Artist DTOs for host services
* feat(plugins): add Matcher host-service interface and MatchSong DTO
* feat(plugins): generate Matcher host wrappers, PDK clients, and matcher permission
* feat(plugins): implement Matcher host service and MediaFile-to-Track converter
Also fixes an ndpgen bug where ParseDirectory parsed each host-service file
in isolation, so a service method referencing a struct defined in another
file of the same package (host.Track in track.go) could not be resolved.
ParseDirectory now collects package-wide structs in a first pass, mirroring
ParseCapabilities; PDK clients regenerated cleanly via make gen.
* feat(plugins): register Matcher host service in the manager
* test(plugins): add Matcher host service integration test plugin
* refactor(plugins): simplify matcher converter and parser file collection
- toTrack: use gg.V for nil-able field derefs and slice.Map for genres/
participants, removing the repeated nil-guard blocks and inner loop
- manager_loader: drop the redundant ds==nil guard (loadEnabledPlugins
already gates a nil DataStore), matching the other service entries
- ndpgen parser: extract collectGoFiles, shared by ParseDirectory and
ParseCapabilities instead of duplicating the file-filter loop
* fix(plugins): keep nullable Track numerics as pointers
ReplayGain values, BitDepth, and BPM are nullable in model.MediaFile, and 0
is a valid measured ReplayGain value. Flattening them to value types with
omitempty made a real 0 indistinguishable from absent. Model them as *float64
/*int32 so plugins can tell 'no data' from a measured 0. Regenerated PDK
clients; converter passes the model pointers through (RG) or maps *int->*int32
(BitDepth/BPM).
* refactor(plugins): trim redundant pass labels in ndpgen ParseDirectory
The function doc already explains the two-pass approach; the inline labels
restated it. Reduce to bare waypoints.
* fix(plugins): gate Track.Path on library filesystem permission
MatchSongs copied mf.Path into every result unconditionally, letting a plugin
with only the matcher permission enumerate on-disk file paths by matching known
songs. Gate Path behind library.filesystem, matching the Library host service.
toTrack is now a method carrying the permission flag.
* refactor(plugins): align MatchSong JSON casing and parse Go files once
- MatchSong: artistMBID/albumMBID JSON tags -> artistMbid/albumMbid so the Go
wire format matches the Rust SDK's camelCase serialization (cross-SDK fix)
- MatchSongs doc reworded to language-neutral 'empty (absent)' so generated
Rust/Python client docs no longer say Go-specific 'nil'
- ndpgen: parse each package file once (parseGoFiles) and reuse the ASTs across
both passes in ParseDirectory and ParseCapabilities, instead of re-parsing
* refactor(plugins): use shared types for Matcher host service
Move the Matcher host service onto the shared plugins/types package instead
of the host-local MatchSong and Track structs. MatchSongs now takes
[]types.SongRef and returns []*types.Track, dropping host.MatchSong and moving
host.Track (with its host.Artist dependency collapsed onto types.ArtistRef) into
plugins/types. ArtistRef gains SortName and SubRole so it can back a track's
Participants.
SongRef gains a millisecond-precision DurationMs field that supersedes the now
deprecated seconds-based Duration, with DurationInMs() resolving the effective
value and SetDurationMs() keeping both fields in sync when populating a SongRef
to send to a plugin.
The ndpgen host-wrapper template only ever imported context, json and extism, so
a host service referencing the shared types package produced uncompilable code.
Emit the plugins/types import when the service references shared types directly
(gated on the existing Service.ImportsSharedTypes), matching the client template,
and cover it with GenerateHost tests. This removes the need for host-local
re-export aliases. Regenerated the Go/Rust/Python PDK and capability schemas
accordingly.
* test(plugins): cover SongRef duration and artist conversion
Add unit coverage for the new SongRef behavior: SetDurationMs populating both
DurationMs and the deprecated seconds field, and the SongRef-to-agents.Song
conversion preferring DurationMs over Duration and the Artists list over the
scalar Artist/ArtistMBID.
Extract the inline SongRef-to-agents.Song closure in MatchSongs into a named
toAgentSong function so the conversion can be asserted directly rather than only
through the opaque matcher. The end-to-end wire shape of the moved types is
already validated by the existing MatcherService integration test, so no new
WASM-boundary test is needed.
* fix(plugins): harden and unify SongRef-to-agents.Song duration conversion
Address findings from a code review of the matcher host service:
- DurationInMs now clamps a negative deprecated-seconds value to 0 instead of
converting it through uint32, which previously wrapped a value like -1s into a
~49-day duration that corrupted the matcher's duration-proximity tiebreaker.
- Replace the unused SetDurationMs(uint32) with SetDuration(seconds float32),
which takes the unit callers actually hold (model.MediaFile.Duration is
float32 seconds) and centralizes the seconds-to-ms conversion. Wire it into
mediaFileToSongRef so outbound SongRefs carry both duration fields in sync.
- Make the metadata-agent path use DurationInMs() so every consumer of the
shared SongRef honors the DurationMs-over-Duration precedence contract; a
plugin sending only DurationMs no longer loses its duration on that path.
- Collapse the matcher's duplicate toAgentSong/agentArtists helpers into the
existing songRefToAgentSong converter, so there is a single SongRef-to-Song
mapping. Tests narrowed to the duration cases, with artist precedence still
covered in metadata_agent_test.go.
* feat(plugins): allow Matcher host service to scope a match to a user
Add an options struct to the Matcher host service so a plugin can run a match as
a specific user. When MatchOptions.Username is set, the match is run in that
user's context: their favourites and ratings inform the matcher's tiebreaker, and
the returned tracks carry that user's per-user annotations (Starred, StarredAt,
Rating, PlayCount, PlayDate, added to types.Track). An empty username preserves
the previous unscoped behaviour.
Cross-user access is gated by the same allowedUsers/allUsers permission the Users
and SubsonicAPI host services use: an unknown username, or one the plugin is not
permitted to act as, returns an error. User-library access applies automatically
once the user is in context (applyLibraryFilter). Independently, results are now
restricted to the libraries the plugin itself may access via the precomputed
libraryAccess set, dropping any matched track outside that set (the input index
stays unmatched) — this applies even without a username and even for an
admin-scoped user.
core/matcher is unchanged: it already loads and uses annotations and applies
user-library filtering from context, so the feature works by deriving the request
context and post-filtering by plugin library access in the host adapter. The new
opts parameter and the Track annotation fields are propagated to all PDK clients
(Go/Rust/Python) by make gen.
* fix(plugins): correct Matcher library scope and unify user-access checks
Address findings from a code review of the user-scoped Matcher host service:
- The plugin-library post-filter previously dropped every match for a plugin that
holds only the matcher permission, because library config is tied to the Library
permission and a matcher-only plugin has none (empty allowedLibraries,
AllLibraries=false). Gate the filter on whether the plugin actually declared the
Library permission: matcher-only plugins are no longer library-restricted, while
plugins that opt into a library scope are enforced as before. The per-user
library filter (applyLibraryFilter) still applies whenever a non-admin user is
scoped.
- resolveUser collapsed every FindByUsername error (including transient DB
failures) into a misleading "not found". Extract a shared userAccess type
(alongside libraryAccess) whose resolve() distinguishes model.ErrNotFound from a
real backend error and authorizes the user against the allowed set. The Matcher
service now uses it, and host_subsonicapi shares the same userAccess type for its
permission check (preserving its existing error messages), removing a third
divergent copy of the resolve-and-authorize logic.
- Document in the matcher tests that the mock MediaFileRepo returns annotations
unconditionally, so the unit tests cover the adapter's scoped-flag gating and
access checks but not the SQL per-user join. Add tests for the library-permission
gating and for surfacing a backend error instead of masking it as not-found.
* fix(plugins): require a library scope for Matcher, fail closed
Reverse the permissive default introduced when fixing the library post-filter: a
Matcher plugin now must be granted a library scope (all libraries, or at least one
specific library) and MatchSongs rejects the request with "no libraries
configured" when it has none, instead of either silently matching nothing or
defaulting to every library.
This mirrors how the SubsonicAPI host service requires a user scope
(checkPermissions errors with "no users configured" when none is set): the check
is a runtime guard via libraryAccess.configured(), needs no manifest changes, and
keeps the failure loud rather than silent. The per-match library post-filter then
always applies, and the restrictLibraries flag added in the previous commit is
removed.
* fix(plugins): require library permission for matcher; guard nil user
Close the gap where a plugin declaring only the matcher permission loaded
successfully but failed every MatchSongs call with "no libraries configured",
with no way for an admin to grant a library scope (the library-config UI is gated
on the library permission). Add a cross-field manifest rule, mirroring the
existing "subsonicapi requires users" rule, so the matcher permission requires
the library permission to be declared. A matcher plugin therefore also surfaces
the library-config panel and is subject to the existing load/enable-time library
configuration gate, making the fail-closed library check reachable and fixable
rather than a silent dead end. The test plugin manifest now declares the library
permission accordingly.
Also restore a defensive nil-user guard in userAccess.resolve: if a DataStore's
FindByUsername ever returns (nil, nil) instead of model.ErrNotFound, return a
clean "not found" error rather than dereferencing a nil *model.User.
* feat(plugins): expose track AverageRating in Matcher results
Add AverageRating to the Matcher's Track DTO. Unlike the per-user annotations
(Starred, Rating, PlayCount, ...), AverageRating is an aggregate stored on the
track itself and is loaded regardless of the request user, so it is populated
unconditionally rather than gated on a scoped username. Propagated to the PDK
types by make gen.
Signed-off-by: Deluan <deluan@navidrome.org>
* style(plugins): trim verbose comments in matcher host service
Condense the over-long explanatory comments added across the matcher host
service to one-liners that state the why, and simplify the ptrInt32/unixPtr
helpers to Go 1.26's new(value). No behavior change.
* refactor(plugins): pass userAccess into newSubsonicAPIService
Move newUserAccess construction to the loader call site so the SubsonicAPI service
constructor takes a userAccess value directly, matching newMatcherService. Pure
refactor: the service already stored a userAccess internally, so behavior and error
messages are unchanged.
* fix(plugins): regenerate PDK and drop omitempty from AverageRating
Re-run make gen so the generated PDK doc comments match the source comment
trimmed in an earlier commit (the source was simplified but the PDK was not
regenerated, leaving the committed files stale — a 'generated files up to date'
hazard).
Also drop omitempty from Track.AverageRating: it is always set (0 when unrated),
so it should be present in the payload like the other always-set fields
(BirthTime/CreatedAt/UpdatedAt), not dropped at zero. Tag change propagated to the
PDK by the same regeneration.
* fix(plugins): reject user-scoped match before lookup when plugin has no user scope
A matcher plugin requires the library permission but not the users permission, so
a matcher-only plugin always has an empty user scope (allUsers=false, no allowed
users). MatchSongs still ran FindByUsername for any opts.Username before checking
authorization and returned distinguishable errors ('user X not found' vs 'not
allowed to act as user X'), letting such a plugin enumerate account names from the
error text.
Guard userAccess.resolve to reject with a single fixed error before the lookup when
the plugin has no user scope, mirroring how the SubsonicAPI service short-circuits
with 'no users configured'. The unscoped match path (no username) is unaffected, so
matcher-only plugins still match normally.
* fix(plugins): run unscoped matcher as admin, not the inherited request user
A matcher host call can arrive on a context that already carries a request user
(e.g. a plugin capability invoked while serving that user's request — extism
propagates the call context into host functions). With no opts.Username, MatchSongs
passed that context straight through, so the media-file repository applied the
caller's library filter and per-user annotation ranking to an explicitly unscoped
match.
Set the user context explicitly: a username scopes to that user (overriding any
inherited one), and an unscoped match runs under adminContext so only the plugin's
own library scope constrains results. Adds tests using a context-capturing
DataStore to assert the user the matcher resolves in both cases.
* chore(plugins): drop the generated Python matcher PDK
The Python plugin PDK is no longer supported (ndpgen generates only Go and Rust
clients), so remove the stale generated nd_host_matcher.py rather than leave a
client that drifts from the host interface.
* docs(plugins): deprecate SongRef.Artist/ArtistMBID in favor of Artists
Mark the scalar single-artist fields deprecated; Artists (the ArtistRef list) is
the preferred way to supply artist data and already takes precedence for matching.
Propagated to the PDK and capability schemas by make gen.
* refactor(plugins): flatten Track.Participants and add Role to ArtistRef
Change Track.Participants from map[role][]ArtistRef to a flat []ArtistRef, and give
ArtistRef a Role field (the participation category: artist/composer/performer/...)
alongside SubRole (a specialization within a role, e.g. the instrument for a
performer). In the flat list each entry now self-describes its role rather than
relying on a map key, matching how SongRef.Artists is already a flat list; the
converter tags each entry with its role and emits them in a stable role order.
Propagated to the PDK and capability schemas by make gen.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
385e75e9a9
commit
f580d3ea76
38 changed files with 1937 additions and 71 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */ -}}
|
||||
|
|
|
|||
27
plugins/host/matcher.go
Normal file
27
plugins/host/matcher.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
91
plugins/host/matcher_gen.go
Normal file
91
plugins/host/matcher_gen.go
Normal file
|
|
@ -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
|
||||
}
|
||||
194
plugins/host_matcher.go
Normal file
194
plugins/host_matcher.go
Normal file
|
|
@ -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)
|
||||
490
plugins/host_matcher_test.go
Normal file
490
plugins/host_matcher_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
77
plugins/pdk/go/host/nd_host_matcher.go
Normal file
77
plugins/pdk/go/host/nd_host_matcher.go
Normal file
|
|
@ -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
|
||||
}
|
||||
44
plugins/pdk/go/host/nd_host_matcher_stub.go
Normal file
44
plugins/pdk/go/host/nd_host_matcher_stub.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
65
plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs
Normal file
65
plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs
Normal file
|
|
@ -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<nd_pdk_types::SongRef>,
|
||||
opts: MatchOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MatcherMatchSongsResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<Option<nd_pdk_types::Track>>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn matcher_matchsongs(input: Json<MatcherMatchSongsRequest>) -> Json<MatcherMatchSongsResponse>;
|
||||
}
|
||||
|
||||
/// 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<nd_pdk_types::SongRef> parameter.
|
||||
/// * `opts` - MatchOptions parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The results value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn match_songs(songs: Vec<nd_pdk_types::SongRef>, opts: MatchOptions) -> Result<Vec<Option<nd_pdk_types::Track>>, 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)
|
||||
}
|
||||
|
|
@ -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<i32>,
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub comment: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bpm: Option<i32>,
|
||||
#[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<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rg_album_peak: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rg_track_gain: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rg_track_peak: Option<f64>,
|
||||
/// 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<i64>,
|
||||
#[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<i64>,
|
||||
/// Composite
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub tags: std::collections::HashMap<String, Vec<String>>,
|
||||
/// 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<ArtistRef>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
16
plugins/testdata/test-matcher/go.mod
vendored
Normal file
16
plugins/testdata/test-matcher/go.mod
vendored
Normal file
|
|
@ -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
|
||||
14
plugins/testdata/test-matcher/go.sum
vendored
Normal file
14
plugins/testdata/test-matcher/go.sum
vendored
Normal file
|
|
@ -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=
|
||||
56
plugins/testdata/test-matcher/main.go
vendored
Normal file
56
plugins/testdata/test-matcher/main.go
vendored
Normal file
|
|
@ -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() {}
|
||||
14
plugins/testdata/test-matcher/manifest.json
vendored
Normal file
14
plugins/testdata/test-matcher/manifest.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
93
plugins/types/track.go
Normal file
93
plugins/types/track.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
17
plugins/types/types_suite_test.go
Normal file
17
plugins/types/types_suite_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
50
plugins/types/types_test.go
Normal file
50
plugins/types/types_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue