mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-30 16:56:22 -04:00
* 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>
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/plugins/capabilities"
|
|
"github.com/navidrome/navidrome/plugins/types"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
|
|
// Detected when the plugin exports at least one of the metadata agent functions.
|
|
const CapabilityMetadataAgent Capability = "MetadataAgent"
|
|
|
|
// Export function names (snake_case as per design)
|
|
const (
|
|
FuncGetArtistMBID = "nd_get_artist_mbid"
|
|
FuncGetArtistURL = "nd_get_artist_url"
|
|
FuncGetArtistBiography = "nd_get_artist_biography"
|
|
FuncGetSimilarArtists = "nd_get_similar_artists"
|
|
FuncGetArtistImages = "nd_get_artist_images"
|
|
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
|
|
FuncGetAlbumInfo = "nd_get_album_info"
|
|
FuncGetAlbumImages = "nd_get_album_images"
|
|
FuncGetSimilarSongsByTrack = "nd_get_similar_songs_by_track"
|
|
FuncGetSimilarSongsByAlbum = "nd_get_similar_songs_by_album"
|
|
FuncGetSimilarSongsByArtist = "nd_get_similar_songs_by_artist"
|
|
)
|
|
|
|
func init() {
|
|
registerCapability(
|
|
CapabilityMetadataAgent,
|
|
FuncGetArtistMBID,
|
|
FuncGetArtistURL,
|
|
FuncGetArtistBiography,
|
|
FuncGetSimilarArtists,
|
|
FuncGetArtistImages,
|
|
FuncGetArtistTopSongs,
|
|
FuncGetAlbumInfo,
|
|
FuncGetAlbumImages,
|
|
FuncGetSimilarSongsByTrack,
|
|
FuncGetSimilarSongsByAlbum,
|
|
FuncGetSimilarSongsByArtist,
|
|
)
|
|
}
|
|
|
|
func newMetadataAgent(p *plugin) *MetadataAgent {
|
|
return &MetadataAgent{name: p.name, plugin: p}
|
|
}
|
|
|
|
// MetadataAgent is an adapter that wraps an Extism plugin and implements
|
|
// the agents interfaces for metadata retrieval.
|
|
type MetadataAgent struct {
|
|
name string
|
|
plugin *plugin
|
|
}
|
|
|
|
// AgentName returns the plugin name
|
|
func (a *MetadataAgent) AgentName() string {
|
|
return a.name
|
|
}
|
|
|
|
// --- Interface implementations ---
|
|
|
|
// GetArtistMBID retrieves the MusicBrainz ID for an artist
|
|
func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
|
input := capabilities.ArtistMBIDRequest{ID: id, Name: name}
|
|
result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input)
|
|
if err != nil {
|
|
return "", errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil || result.MBID == "" {
|
|
return "", agents.ErrNotFound
|
|
}
|
|
|
|
return result.MBID, nil
|
|
}
|
|
|
|
// GetArtistURL retrieves the external URL for an artist
|
|
func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
|
|
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input)
|
|
if err != nil {
|
|
return "", errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
if result == nil || result.URL == "" {
|
|
return "", agents.ErrNotFound
|
|
}
|
|
return result.URL, nil
|
|
}
|
|
|
|
// GetArtistBiography retrieves the biography for an artist
|
|
func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
|
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
|
|
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input)
|
|
if err != nil {
|
|
return "", errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil || result.Biography == "" {
|
|
return "", agents.ErrNotFound
|
|
}
|
|
|
|
return result.Biography, nil
|
|
}
|
|
|
|
// GetSimilarArtists retrieves similar artists
|
|
func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
|
input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)}
|
|
result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input)
|
|
if err != nil {
|
|
return nil, errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil || len(result.Artists) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
|
|
artists := make([]agents.Artist, len(result.Artists))
|
|
for i, ar := range result.Artists {
|
|
artists[i] = agents.Artist{ID: ar.ID, Name: ar.Name, MBID: ar.MBID}
|
|
}
|
|
|
|
return artists, nil
|
|
}
|
|
|
|
// GetArtistImages retrieves images for an artist
|
|
func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
|
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
|
|
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input)
|
|
if err != nil {
|
|
return nil, errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil || len(result.Images) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
|
|
images := make([]agents.ExternalImage, len(result.Images))
|
|
for i, img := range result.Images {
|
|
images[i] = agents.ExternalImage{URL: img.URL, Size: int(img.Size)}
|
|
}
|
|
|
|
return images, nil
|
|
}
|
|
|
|
// GetArtistTopSongs retrieves top songs for an artist
|
|
func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
|
input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)}
|
|
result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input)
|
|
if err != nil {
|
|
return nil, errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil || len(result.Songs) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
|
|
return songRefsToAgentSongs(result.Songs), nil
|
|
}
|
|
|
|
// GetAlbumInfo retrieves album information
|
|
func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
|
input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid}
|
|
result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input)
|
|
if err != nil {
|
|
return nil, errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
|
|
return &agents.AlbumInfo{
|
|
Name: result.Name,
|
|
MBID: result.MBID,
|
|
Description: result.Description,
|
|
URL: result.URL,
|
|
}, nil
|
|
}
|
|
|
|
// GetAlbumImages retrieves images for an album
|
|
func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
|
input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid}
|
|
result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input)
|
|
if err != nil {
|
|
return nil, errors.Join(agents.ErrNotFound, err)
|
|
}
|
|
|
|
if result == nil || len(result.Images) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
|
|
images := make([]agents.ExternalImage, len(result.Images))
|
|
for i, img := range result.Images {
|
|
images[i] = agents.ExternalImage{URL: img.URL, Size: int(img.Size)}
|
|
}
|
|
|
|
return images, nil
|
|
}
|
|
|
|
func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) {
|
|
result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result == nil || len(result.Songs) == 0 {
|
|
return nil, agents.ErrNotFound
|
|
}
|
|
return songRefsToAgentSongs(result.Songs), nil
|
|
}
|
|
|
|
// GetSimilarSongsByTrack retrieves songs similar to a specific track
|
|
func (a *MetadataAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
|
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByTrackRequest](ctx, a.plugin, FuncGetSimilarSongsByTrack, capabilities.SimilarSongsByTrackRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
|
|
}
|
|
|
|
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album
|
|
func (a *MetadataAgent) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
|
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByAlbumRequest](ctx, a.plugin, FuncGetSimilarSongsByAlbum, capabilities.SimilarSongsByAlbumRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
|
|
}
|
|
|
|
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog
|
|
func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
|
|
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
|
|
}
|
|
|
|
// songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single
|
|
// Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead
|
|
// of the artists array, they are folded into a one-element Artists list here.
|
|
func songRefToAgentSong(s types.SongRef) agents.Song {
|
|
var artists []agents.Artist
|
|
switch {
|
|
case len(s.Artists) > 0:
|
|
artists = make([]agents.Artist, len(s.Artists))
|
|
for i, a := range s.Artists {
|
|
artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID}
|
|
}
|
|
case s.Artist != "" || s.ArtistMBID != "":
|
|
artists = []agents.Artist{{Name: s.Artist, MBID: s.ArtistMBID}}
|
|
}
|
|
return agents.Song{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
MBID: s.MBID,
|
|
ISRC: s.ISRC,
|
|
Artists: artists,
|
|
Album: s.Album,
|
|
AlbumMBID: s.AlbumMBID,
|
|
Duration: s.DurationInMs(),
|
|
}
|
|
}
|
|
|
|
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
|
|
func songRefsToAgentSongs(refs []types.SongRef) []agents.Song {
|
|
return slice.Map(refs, songRefToAgentSong)
|
|
}
|
|
|
|
// Verify interface implementations at compile time
|
|
var (
|
|
_ agents.Interface = (*MetadataAgent)(nil)
|
|
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
|
|
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
|
|
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
|
|
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
|
|
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
|
|
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
|
|
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
|
|
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
|
|
_ agents.SimilarSongsByTrackRetriever = (*MetadataAgent)(nil)
|
|
_ agents.SimilarSongsByAlbumRetriever = (*MetadataAgent)(nil)
|
|
_ agents.SimilarSongsByArtistRetriever = (*MetadataAgent)(nil)
|
|
)
|