diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f66df2e75..d1cc4fc46 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -70,9 +70,9 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) @@ -92,9 +92,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() @@ -121,9 +121,9 @@ func CreatePublicRouter() *public.Router { fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() @@ -169,9 +169,9 @@ func CreateScanner(ctx context.Context) model.Scanner { fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) @@ -188,9 +188,9 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) @@ -213,7 +213,8 @@ func getPluginManager() *plugins.Manager { dataStore := persistence.New(sqlDB) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) + matcherMatcher := matcher.New(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher) return manager } diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f069d3fb6..db835bb42 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -324,7 +324,7 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) // collectPlugins collects information about installed plugins func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo { // TODO Fix import/inject cycles - manager := plugins.GetManager(c.ds, events.GetBroker(), nil) + manager := plugins.GetManager(c.ds, events.GetBroker(), nil, nil) info := manager.GetPluginInfo() result := make(map[string]insights.PluginInfo, len(info)) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 3da24706c..a9cdcf11c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -113,7 +113,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, if err != nil { return err } - if pls.IsSmartPlaylist() { + if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() { return model.ErrNotAuthorized } if !usr.IsAdmin && pls.OwnerID != usr.ID { @@ -163,6 +163,12 @@ func (s *playlists) Update(ctx context.Context, playlistID string, if err != nil { return err } + // Plugin playlists allow public toggle and cover art, but block name/comment changes + if pls.IsPluginPlaylist() { + if (name != nil && *name != pls.Name) || (comment != nil && *comment != pls.Comment) { + return model.ErrNotAuthorized + } + } return s.ds.WithTxImmediate(func(tx model.DataStore) error { repo := tx.Playlist(ctx) @@ -213,13 +219,13 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli return pls, nil } -// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist). +// checkTracksEditable verifies the user can modify tracks (ownership + not smart/plugin playlist). func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { pls, err := s.checkWritable(ctx, playlistID) if err != nil { return nil, err } - if pls.IsSmartPlaylist() { + if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() { return nil, model.ErrNotAuthorized } return pls, nil diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 52d5c88d8..86cac3c11 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -80,6 +80,8 @@ var _ = Describe("Playlists", func() { "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix"}, } ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) @@ -125,6 +127,12 @@ var _ = Describe("Playlists", func() { _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) + + It("denies replacing tracks on a plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-plugin", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) }) Describe("Update", func() { @@ -137,6 +145,8 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix"}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) @@ -182,12 +192,45 @@ var _ = Describe("Playlists", func() { Expect(err).To(MatchError(model.ErrNotAuthorized)) }) + It("denies adding tracks to a plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-plugin", nil, nil, nil, []string{"song-1"}, nil) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("denies removing tracks from a plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-plugin", nil, nil, nil, nil, []int{0}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + It("allows metadata updates on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) newName := "Updated Smart" err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) + + It("denies name update on a plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + newName := "New Name" + err := ps.Update(ctx, "pls-plugin", &newName, nil, nil, nil, nil) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("denies comment update on a plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + newComment := "New Comment" + err := ps.Update(ctx, "pls-plugin", nil, &newComment, nil, nil, nil) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + + It("allows public toggle on a plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + public := true + err := ps.Update(ctx, "pls-plugin", nil, nil, &public, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("AddTracks", func() { @@ -199,6 +242,8 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks @@ -232,6 +277,12 @@ var _ = Describe("Playlists", func() { Expect(err).To(MatchError(model.ErrNotAuthorized)) }) + It("denies editing plugin playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "pls-plugin", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + It("returns error when playlist not found", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"}) @@ -248,6 +299,8 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix"}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) @@ -266,6 +319,12 @@ var _ = Describe("Playlists", func() { Expect(err).To(MatchError(model.ErrNotAuthorized)) }) + It("denies on plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.RemoveTracks(ctx, "pls-plugin", []string{"track-1"}) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) + It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"}) @@ -282,6 +341,8 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix"}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) @@ -299,6 +360,12 @@ var _ = Describe("Playlists", func() { err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) + + It("denies on plugin playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.ReorderTrack(ctx, "pls-plugin", 1, 3) + Expect(err).To(MatchError(model.ErrNotAuthorized)) + }) }) Describe("SetImage", func() { diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3fecda0d5..5e0042d04 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -3,6 +3,7 @@ package playlists import ( "context" "errors" + "slices" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -68,6 +69,9 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri pls.UploadedImage = "" // Managed by image upload endpoint pls.ExternalImageURL = "" // Managed by M3U import / plugins only pls.EvaluatedAt = nil // Server-managed + pls.PluginID = "" // Server-managed (plugin system) + pls.PluginPlaylistID = "" // Server-managed (plugin system) + pls.ValidUntil = nil // Server-managed (plugin system) err := s.ds.Playlist(ctx).Put(pls) if err != nil { return "", err @@ -93,6 +97,19 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { return rest.ErrPermissionDenied } + // Plugin playlists allow public and ownership changes, but block name/comment + if current.IsPluginPlaylist() && slices.ContainsFunc(cols, func(c string) bool { + switch c { + case "name": + return entity.Name != current.Name + case "comment": + return entity.Comment != current.Comment + default: + return false + } + }) { + return rest.ErrPermissionDenied + } // Apply ownership change (admin only) if entity.OwnerID != "" { current.OwnerID = entity.OwnerID diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 097bc6310..832cdbda7 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -74,6 +74,9 @@ var _ = Describe("REST Adapter", func() { UploadedImage: "injected-image-path", ExternalImageURL: "http://evil.example.com/ssrf", EvaluatedAt: &now, + PluginID: "fake-plugin", + PluginPlaylistID: "fake-playlist-id", + ValidUntil: &now, } _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) @@ -90,6 +93,9 @@ var _ = Describe("REST Adapter", func() { Expect(saved.UploadedImage).To(BeEmpty()) Expect(saved.ExternalImageURL).To(BeEmpty()) Expect(saved.EvaluatedAt).To(BeNil()) + Expect(saved.PluginID).To(BeEmpty()) + Expect(saved.PluginPlaylistID).To(BeEmpty()) + Expect(saved.ValidUntil).To(BeNil()) }) }) @@ -102,6 +108,42 @@ var _ = Describe("REST Adapter", func() { Expect(err).ToNot(HaveOccurred()) }) + It("denies name change on plugin playlist", func() { + mockPlsRepo.Data["pls-plugin"] = &model.Playlist{ + ID: "pls-plugin", Name: "Plugin PL", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix", + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Changed Name", Comment: ""} + err := repo.Update("pls-plugin", pls, "name", "comment") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + + It("denies comment change on plugin playlist", func() { + mockPlsRepo.Data["pls-plugin"] = &model.Playlist{ + ID: "pls-plugin", Name: "Plugin PL", Comment: "", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix", + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Plugin PL", Comment: "new comment"} + err := repo.Update("pls-plugin", pls, "name", "comment") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + + It("allows public toggle on plugin playlist", func() { + mockPlsRepo.Data["pls-plugin"] = &model.Playlist{ + ID: "pls-plugin", Name: "Plugin PL", OwnerID: "user-1", + PluginID: "test-plugin", PluginPlaylistID: "daily-mix", + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Plugin PL", Public: true} + err := repo.Update("pls-plugin", pls, "public") + Expect(err).ToNot(HaveOccurred()) + }) + It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) repo = ps.NewRepository(ctx).(rest.Persistable) diff --git a/db/migrations/20260305051806_add_plugin_playlist_fields.go b/db/migrations/20260305051806_add_plugin_playlist_fields.go new file mode 100644 index 000000000..cadc7ec8e --- /dev/null +++ b/db/migrations/20260305051806_add_plugin_playlist_fields.go @@ -0,0 +1,32 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPluginPlaylistFields, downAddPluginPlaylistFields) +} + +func upAddPluginPlaylistFields(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +ALTER TABLE playlist ADD COLUMN plugin_id VARCHAR(255) NOT NULL DEFAULT ''; +ALTER TABLE playlist ADD COLUMN plugin_playlist_id VARCHAR(255) NOT NULL DEFAULT ''; +ALTER TABLE playlist ADD COLUMN valid_until DATETIME DEFAULT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_playlist_plugin ON playlist(plugin_id, plugin_playlist_id, owner_id) WHERE plugin_id != ''; +`) + return err +} + +func downAddPluginPlaylistFields(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +DROP INDEX IF EXISTS idx_playlist_plugin; +ALTER TABLE playlist DROP COLUMN valid_until; +ALTER TABLE playlist DROP COLUMN plugin_playlist_id; +ALTER TABLE playlist DROP COLUMN plugin_id; +`) + return err +} diff --git a/model/playlist.go b/model/playlist.go index e2f93993d..7ed3e259b 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -30,12 +30,25 @@ type Playlist struct { // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"` + + // Plugin playlist attributes + PluginID string `structs:"plugin_id" json:"pluginId,omitempty"` + PluginPlaylistID string `structs:"plugin_playlist_id" json:"pluginPlaylistId,omitempty"` + ValidUntil *time.Time `structs:"valid_until" json:"validUntil,omitempty"` +} + +func (pls Playlist) IsReadOnly() bool { + return pls.IsSmartPlaylist() || pls.IsPluginPlaylist() } func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +func (pls Playlist) IsPluginPlaylist() bool { + return pls.PluginID != "" +} + func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9bbc41c5c..f3a45a267 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -50,8 +50,9 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe r.ctx = ctx r.db = db r.registerModel(&model.Playlist{}, map[string]filterFunc{ - "q": playlistFilter, - "smart": smartPlaylistFilter, + "q": playlistFilter, + "smart": smartPlaylistFilter, + "readonly": readonlyPlaylistFilter, }) r.setSortMappings(map[string]string{ "owner_name": "owner_name", @@ -73,6 +74,13 @@ func smartPlaylistFilter(string, any) Sqlizer { } } +func readonlyPlaylistFilter(string, any) Sqlizer { + return And{ + smartPlaylistFilter("", nil), + Or{Eq{"plugin_id": ""}, Eq{"plugin_id": nil}}, + } +} + func (r *playlistRepository) userFilter() Sqlizer { user := loggedUser(r.ctx) if user.IsAdmin { diff --git a/plugins/capabilities/playlist_provider.go b/plugins/capabilities/playlist_provider.go new file mode 100644 index 000000000..72ebaca7d --- /dev/null +++ b/plugins/capabilities/playlist_provider.go @@ -0,0 +1,72 @@ +package capabilities + +// PlaylistProvider provides dynamically-generated playlists (e.g., "Daily Mix", +// personalized recommendations). Plugins implementing this capability expose two +// functions: GetAvailablePlaylists for lightweight discovery and GetPlaylist for +// fetching the heavy payload (tracks, metadata). +// +//nd:capability name=playlistprovider required=true +type PlaylistProvider interface { + // GetAvailablePlaylists returns the list of playlists this plugin provides. + //nd:export name=nd_playlist_provider_get_available_playlists + GetAvailablePlaylists(GetAvailablePlaylistsRequest) (GetAvailablePlaylistsResponse, error) + + // GetPlaylist returns the full data for a single playlist (tracks, metadata). + //nd:export name=nd_playlist_provider_get_playlist + GetPlaylist(GetPlaylistRequest) (GetPlaylistResponse, error) +} + +// GetAvailablePlaylistsRequest is the request for GetAvailablePlaylists. +type GetAvailablePlaylistsRequest struct{} + +// GetAvailablePlaylistsResponse is the response for GetAvailablePlaylists. +type GetAvailablePlaylistsResponse struct { + // Playlists is the list of playlists provided by this plugin. + Playlists []PlaylistInfo `json:"playlists"` + // RefreshInterval is the number of seconds until the next GetAvailablePlaylists call. + // 0 means never re-discover. + RefreshInterval int64 `json:"refreshInterval"` + // RetryInterval is the number of seconds before retrying a failed GetPlaylist call. + // 0 means no automatic retry for transient errors. + RetryInterval int64 `json:"retryInterval"` +} + +// PlaylistInfo identifies a plugin playlist and its target user. +type PlaylistInfo struct { + // ID is the plugin-scoped unique identifier for this playlist. + ID string `json:"id"` + // OwnerUsername is the Navidrome username that owns this playlist. + OwnerUsername string `json:"ownerUsername"` +} + +// GetPlaylistRequest is the request for GetPlaylist. +type GetPlaylistRequest struct { + // ID is the plugin-scoped playlist ID. + ID string `json:"id"` +} + +// GetPlaylistResponse is the response for GetPlaylist. +type GetPlaylistResponse struct { + // Name is the display name of the playlist. + Name string `json:"name"` + // Description is an optional description for the playlist. + Description string `json:"description,omitempty"` + // CoverArtURL is an optional external URL for the playlist cover art. + CoverArtURL string `json:"coverArtUrl,omitempty"` + // Tracks is the list of songs in the playlist, using SongRef for matching. + Tracks []SongRef `json:"tracks"` + // ValidUntil is a unix timestamp indicating when this playlist data expires. + // 0 means static (never refresh). + ValidUntil int64 `json:"validUntil"` +} + +// PlaylistProviderError represents an error type for playlist provider operations. +type PlaylistProviderError string + +const ( + // PlaylistProviderErrorNotFound indicates a playlist is currently unavailable. + PlaylistProviderErrorNotFound PlaylistProviderError = "playlist_provider(not_found)" +) + +// Error implements the error interface for PlaylistProviderError. +func (e PlaylistProviderError) Error() string { return string(e) } diff --git a/plugins/capabilities/playlist_provider.yaml b/plugins/capabilities/playlist_provider.yaml new file mode 100644 index 000000000..66604a6bc --- /dev/null +++ b/plugins/capabilities/playlist_provider.yaml @@ -0,0 +1,127 @@ +version: v1-draft +exports: + nd_playlist_provider_get_available_playlists: + description: GetAvailablePlaylists returns the list of playlists this plugin provides. + input: + $ref: '#/components/schemas/GetAvailablePlaylistsRequest' + contentType: application/json + output: + $ref: '#/components/schemas/GetAvailablePlaylistsResponse' + contentType: application/json + nd_playlist_provider_get_playlist: + description: GetPlaylist returns the full data for a single playlist (tracks, metadata). + input: + $ref: '#/components/schemas/GetPlaylistRequest' + contentType: application/json + output: + $ref: '#/components/schemas/GetPlaylistResponse' + contentType: application/json +components: + schemas: + GetAvailablePlaylistsRequest: + description: GetAvailablePlaylistsRequest is the request for GetAvailablePlaylists. + properties: {} + GetAvailablePlaylistsResponse: + description: GetAvailablePlaylistsResponse is the response for GetAvailablePlaylists. + properties: + playlists: + type: array + description: Playlists is the list of playlists provided by this plugin. + items: + $ref: '#/components/schemas/PlaylistInfo' + refreshInterval: + type: integer + format: int64 + description: |- + RefreshInterval is the number of seconds until the next GetAvailablePlaylists call. + 0 means never re-discover. + retryInterval: + type: integer + format: int64 + description: |- + RetryInterval is the number of seconds before retrying a failed GetPlaylist call. + 0 means no automatic retry for transient errors. + required: + - playlists + - refreshInterval + - retryInterval + GetPlaylistRequest: + description: GetPlaylistRequest is the request for GetPlaylist. + properties: + id: + type: string + description: ID is the plugin-scoped playlist ID. + required: + - id + GetPlaylistResponse: + description: GetPlaylistResponse is the response for GetPlaylist. + properties: + name: + type: string + description: Name is the display name of the playlist. + description: + type: string + description: Description is an optional description for the playlist. + coverArtUrl: + type: string + description: CoverArtURL is an optional external URL for the playlist cover art. + tracks: + type: array + description: Tracks is the list of songs in the playlist, using SongRef for matching. + items: + $ref: '#/components/schemas/SongRef' + validUntil: + type: integer + format: int64 + description: |- + ValidUntil is a unix timestamp indicating when this playlist data expires. + 0 means static (never refresh). + required: + - name + - tracks + - validUntil + PlaylistInfo: + description: PlaylistInfo identifies a plugin playlist and its target user. + properties: + id: + type: string + description: ID is the plugin-scoped unique identifier for this playlist. + ownerUsername: + type: string + description: OwnerUsername is the Navidrome username that owns this playlist. + required: + - id + - ownerUsername + SongRef: + description: SongRef is a reference to a song with metadata for matching. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: Artist is the artist name. + artistMbid: + type: string + description: ArtistMBID is the MusicBrainz artist ID. + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: Duration is the song duration in seconds. + required: + - name diff --git a/plugins/manager.go b/plugins/manager.go index 0c7c91ed8..3e6cc75fe 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -66,16 +67,18 @@ type Manager struct { ds model.DataStore broker events.Broker metrics PluginMetricsRecorder + matcher *matcher.Matcher } // GetManager returns a singleton instance of the plugin manager. // The manager is not started automatically; call Start() to begin loading plugins. -func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder) *Manager { +func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder, mt *matcher.Matcher) *Manager { return singleton.GetInstance(func() *Manager { return &Manager{ ds: ds, broker: broker, metrics: m, + matcher: mt, plugins: make(map[string]*plugin), } }) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index ccda9e4cb..150169a8f 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -373,8 +373,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manifest validation: %w", err) } - m.mu.Lock() - m.plugins[p.ID] = &plugin{ + loadedPlugin := &plugin{ name: p.ID, path: p.Path, manifest: pkg.Manifest, @@ -386,10 +385,19 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), } + m.mu.Lock() + m.plugins[p.ID] = loadedPlugin m.mu.Unlock() // Call plugin init function - callPluginInit(ctx, m.plugins[p.ID]) + callPluginInit(ctx, loadedPlugin) + + // Start PlaylistProvider syncer if capability is detected + if hasCapability(loadedPlugin.capabilities, CapabilityPlaylistProvider) { + syncer := newPlaylistSyncer(m.ctx, p.ID, loadedPlugin, m.ds, m.matcher) + loadedPlugin.closers = append(loadedPlugin.closers, syncer) + go syncer.run() + } return nil } diff --git a/plugins/manifest.go b/plugins/manifest.go index 7484718e3..bd04e6e52 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -65,6 +65,13 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { } } + // PlaylistProvider capability requires users permission + if hasCapability(capabilities, CapabilityPlaylistProvider) { + if m.Permissions == nil || m.Permissions.Users == nil { + return fmt.Errorf("playlist provider capability requires 'users' permission to be declared in manifest") + } + } + // Scheduler permission requires SchedulerCallback capability if m.Permissions != nil && m.Permissions.Scheduler != nil { if !hasCapability(capabilities, CapabilityScheduler) { diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index c45a480eb..723749cb3 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -463,5 +463,48 @@ var _ = Describe("Manifest", func() { err := ValidateWithCapabilities(m, []Capability{}) Expect(err).ToNot(HaveOccurred()) }) + + It("validates playlist provider capability with users permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Users: &UsersPermission{}, + }, + } + + err := ValidateWithCapabilities(m, []Capability{CapabilityPlaylistProvider}) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when playlist provider capability without users permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + } + + err := ValidateWithCapabilities(m, []Capability{CapabilityPlaylistProvider}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("playlist provider")) + Expect(err.Error()).To(ContainSubstring("users")) + }) + + It("returns error when playlist provider has permissions but no users", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + Http: &HTTPPermission{}, + }, + } + + err := ValidateWithCapabilities(m, []Capability{CapabilityPlaylistProvider}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("playlist provider")) + Expect(err.Error()).To(ContainSubstring("users")) + }) }) }) diff --git a/plugins/pdk/go/playlistprovider/playlistprovider.go b/plugins/pdk/go/playlistprovider/playlistprovider.go new file mode 100644 index 000000000..42a99af00 --- /dev/null +++ b/plugins/pdk/go/playlistprovider/playlistprovider.go @@ -0,0 +1,171 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the PlaylistProvider capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package playlistprovider + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// PlaylistProviderError represents an error type for playlist provider operations. +type PlaylistProviderError string + +const ( + // PlaylistProviderErrorNotFound indicates a playlist is currently unavailable. + PlaylistProviderErrorNotFound PlaylistProviderError = "playlist_provider(not_found)" +) + +// Error implements the error interface for PlaylistProviderError. +func (e PlaylistProviderError) Error() string { return string(e) } + +// GetAvailablePlaylistsRequest is the request for GetAvailablePlaylists. +type GetAvailablePlaylistsRequest struct { +} + +// GetAvailablePlaylistsResponse is the response for GetAvailablePlaylists. +type GetAvailablePlaylistsResponse struct { + // Playlists is the list of playlists provided by this plugin. + Playlists []PlaylistInfo `json:"playlists"` + // RefreshInterval is the number of seconds until the next GetAvailablePlaylists call. + // 0 means never re-discover. + RefreshInterval int64 `json:"refreshInterval"` + // RetryInterval is the number of seconds before retrying a failed GetPlaylist call. + // 0 means no automatic retry for transient errors. + RetryInterval int64 `json:"retryInterval"` +} + +// GetPlaylistRequest is the request for GetPlaylist. +type GetPlaylistRequest struct { + // ID is the plugin-scoped playlist ID. + ID string `json:"id"` +} + +// GetPlaylistResponse is the response for GetPlaylist. +type GetPlaylistResponse struct { + // Name is the display name of the playlist. + Name string `json:"name"` + // Description is an optional description for the playlist. + Description string `json:"description,omitempty"` + // CoverArtURL is an optional external URL for the playlist cover art. + CoverArtURL string `json:"coverArtUrl,omitempty"` + // Tracks is the list of songs in the playlist, using SongRef for matching. + Tracks []SongRef `json:"tracks"` + // ValidUntil is a unix timestamp indicating when this playlist data expires. + // 0 means static (never refresh). + ValidUntil int64 `json:"validUntil"` +} + +// PlaylistInfo identifies a plugin playlist and its target user. +type PlaylistInfo struct { + // ID is the plugin-scoped unique identifier for this playlist. + ID string `json:"id"` + // OwnerUsername is the Navidrome username that owns this playlist. + OwnerUsername string `json:"ownerUsername"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// PlaylistProvider requires all methods to be implemented. +// PlaylistProvider provides dynamically-generated playlists (e.g., "Daily Mix", +// personalized recommendations). Plugins implementing this capability expose two +// functions: GetAvailablePlaylists for lightweight discovery and GetPlaylist for +// fetching the heavy payload (tracks, metadata). +type PlaylistProvider interface { + // GetAvailablePlaylists - GetAvailablePlaylists returns the list of playlists this plugin provides. + GetAvailablePlaylists(GetAvailablePlaylistsRequest) (GetAvailablePlaylistsResponse, error) + // GetPlaylist - GetPlaylist returns the full data for a single playlist (tracks, metadata). + GetPlaylist(GetPlaylistRequest) (GetPlaylistResponse, error) +} // Internal implementation holders +var ( + availablePlaylistsImpl func(GetAvailablePlaylistsRequest) (GetAvailablePlaylistsResponse, error) + playlistImpl func(GetPlaylistRequest) (GetPlaylistResponse, error) +) + +// Register registers a playlistprovider implementation. +// All methods are required. +func Register(impl PlaylistProvider) { + availablePlaylistsImpl = impl.GetAvailablePlaylists + playlistImpl = impl.GetPlaylist +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_playlist_provider_get_available_playlists +func _NdPlaylistProviderGetAvailablePlaylists() int32 { + if availablePlaylistsImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetAvailablePlaylistsRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := availablePlaylistsImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_playlist_provider_get_playlist +func _NdPlaylistProviderGetPlaylist() int32 { + if playlistImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetPlaylistRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := playlistImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/playlistprovider/playlistprovider_stub.go b/plugins/pdk/go/playlistprovider/playlistprovider_stub.go new file mode 100644 index 000000000..9fdff8e48 --- /dev/null +++ b/plugins/pdk/go/playlistprovider/playlistprovider_stub.go @@ -0,0 +1,106 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package playlistprovider + +// PlaylistProviderError represents an error type for playlist provider operations. +type PlaylistProviderError string + +const ( + // PlaylistProviderErrorNotFound indicates a playlist is currently unavailable. + PlaylistProviderErrorNotFound PlaylistProviderError = "playlist_provider(not_found)" +) + +// Error implements the error interface for PlaylistProviderError. +func (e PlaylistProviderError) Error() string { return string(e) } + +// GetAvailablePlaylistsRequest is the request for GetAvailablePlaylists. +type GetAvailablePlaylistsRequest struct { +} + +// GetAvailablePlaylistsResponse is the response for GetAvailablePlaylists. +type GetAvailablePlaylistsResponse struct { + // Playlists is the list of playlists provided by this plugin. + Playlists []PlaylistInfo `json:"playlists"` + // RefreshInterval is the number of seconds until the next GetAvailablePlaylists call. + // 0 means never re-discover. + RefreshInterval int64 `json:"refreshInterval"` + // RetryInterval is the number of seconds before retrying a failed GetPlaylist call. + // 0 means no automatic retry for transient errors. + RetryInterval int64 `json:"retryInterval"` +} + +// GetPlaylistRequest is the request for GetPlaylist. +type GetPlaylistRequest struct { + // ID is the plugin-scoped playlist ID. + ID string `json:"id"` +} + +// GetPlaylistResponse is the response for GetPlaylist. +type GetPlaylistResponse struct { + // Name is the display name of the playlist. + Name string `json:"name"` + // Description is an optional description for the playlist. + Description string `json:"description,omitempty"` + // CoverArtURL is an optional external URL for the playlist cover art. + CoverArtURL string `json:"coverArtUrl,omitempty"` + // Tracks is the list of songs in the playlist, using SongRef for matching. + Tracks []SongRef `json:"tracks"` + // ValidUntil is a unix timestamp indicating when this playlist data expires. + // 0 means static (never refresh). + ValidUntil int64 `json:"validUntil"` +} + +// PlaylistInfo identifies a plugin playlist and its target user. +type PlaylistInfo struct { + // ID is the plugin-scoped unique identifier for this playlist. + ID string `json:"id"` + // OwnerUsername is the Navidrome username that owns this playlist. + OwnerUsername string `json:"ownerUsername"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// PlaylistProvider requires all methods to be implemented. +// PlaylistProvider provides dynamically-generated playlists (e.g., "Daily Mix", +// personalized recommendations). Plugins implementing this capability expose two +// functions: GetAvailablePlaylists for lightweight discovery and GetPlaylist for +// fetching the heavy payload (tracks, metadata). +type PlaylistProvider interface { + // GetAvailablePlaylists - GetAvailablePlaylists returns the list of playlists this plugin provides. + GetAvailablePlaylists(GetAvailablePlaylistsRequest) (GetAvailablePlaylistsResponse, error) + // GetPlaylist - GetPlaylist returns the full data for a single playlist (tracks, metadata). + GetPlaylist(GetPlaylistRequest) (GetPlaylistResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ PlaylistProvider) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 85375b525..43b3f428c 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -8,6 +8,7 @@ pub mod lifecycle; pub mod lyrics; pub mod metadata; +pub mod playlistprovider; pub mod scheduler; pub mod scrobbler; pub mod taskworker; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/playlistprovider.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/playlistprovider.rs new file mode 100644 index 000000000..de5da4516 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/playlistprovider.rs @@ -0,0 +1,173 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the PlaylistProvider capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// PlaylistProviderError represents an error type for playlist provider operations. +pub type PlaylistProviderError = &'static str; +/// PlaylistProviderErrorNotFound indicates a playlist is currently unavailable. +pub const PLAYLIST_PROVIDER_ERROR_NOT_FOUND: PlaylistProviderError = "playlist_provider(not_found)"; +/// GetAvailablePlaylistsRequest is the request for GetAvailablePlaylists. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAvailablePlaylistsRequest { +} +/// GetAvailablePlaylistsResponse is the response for GetAvailablePlaylists. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAvailablePlaylistsResponse { + /// Playlists is the list of playlists provided by this plugin. + #[serde(default)] + pub playlists: Vec, + /// RefreshInterval is the number of seconds until the next GetAvailablePlaylists call. + /// 0 means never re-discover. + #[serde(default)] + pub refresh_interval: i64, + /// RetryInterval is the number of seconds before retrying a failed GetPlaylist call. + /// 0 means no automatic retry for transient errors. + #[serde(default)] + pub retry_interval: i64, +} +/// GetPlaylistRequest is the request for GetPlaylist. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetPlaylistRequest { + /// ID is the plugin-scoped playlist ID. + #[serde(default)] + pub id: String, +} +/// GetPlaylistResponse is the response for GetPlaylist. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetPlaylistResponse { + /// Name is the display name of the playlist. + #[serde(default)] + pub name: String, + /// Description is an optional description for the playlist. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub description: String, + /// CoverArtURL is an optional external URL for the playlist cover art. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub cover_art_url: String, + /// Tracks is the list of songs in the playlist, using SongRef for matching. + #[serde(default)] + pub tracks: Vec, + /// ValidUntil is a unix timestamp indicating when this playlist data expires. + /// 0 means static (never refresh). + #[serde(default)] + pub valid_until: i64, +} +/// PlaylistInfo identifies a plugin playlist and its target user. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaylistInfo { + /// ID is the plugin-scoped unique identifier for this playlist. + #[serde(default)] + pub id: String, + /// OwnerUsername is the Navidrome username that owns this playlist. + #[serde(default)] + pub owner_username: String, +} +/// SongRef is a reference to a song with metadata for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// PlaylistProvider requires all methods to be implemented. +/// PlaylistProvider provides dynamically-generated playlists (e.g., "Daily Mix", +/// personalized recommendations). Plugins implementing this capability expose two +/// functions: GetAvailablePlaylists for lightweight discovery and GetPlaylist for +/// fetching the heavy payload (tracks, metadata). +pub trait PlaylistProvider { + /// GetAvailablePlaylists - GetAvailablePlaylists returns the list of playlists this plugin provides. + fn get_available_playlists(&self, req: GetAvailablePlaylistsRequest) -> Result; + /// GetPlaylist - GetPlaylist returns the full data for a single playlist (tracks, metadata). + fn get_playlist(&self, req: GetPlaylistRequest) -> Result; +} + +/// Register all exports for the PlaylistProvider capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_playlistprovider { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_playlist_provider_get_available_playlists( + req: extism_pdk::Json<$crate::playlistprovider::GetAvailablePlaylistsRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::playlistprovider::PlaylistProvider::get_available_playlists(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + #[extism_pdk::plugin_fn] + pub fn nd_playlist_provider_get_playlist( + req: extism_pdk::Json<$crate::playlistprovider::GetPlaylistRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::playlistprovider::PlaylistProvider::get_playlist(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/playlist_provider.go b/plugins/playlist_provider.go new file mode 100644 index 000000000..9ab62a0da --- /dev/null +++ b/plugins/playlist_provider.go @@ -0,0 +1,285 @@ +package plugins + +import ( + "context" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const ( + CapabilityPlaylistProvider Capability = "PlaylistProvider" + + FuncPlaylistProviderGetAvailablePlaylists = "nd_playlist_provider_get_available_playlists" + FuncPlaylistProviderGetPlaylist = "nd_playlist_provider_get_playlist" + + // workChCapacity is the buffer size for the work channel. + workChCapacity = 64 + + // discoveryRetryDelay is how long to wait before retrying a failed GetAvailablePlaylists call. + discoveryRetryDelay = 5 * time.Minute +) + +func init() { + registerCapability( + CapabilityPlaylistProvider, + FuncPlaylistProviderGetAvailablePlaylists, + FuncPlaylistProviderGetPlaylist, + ) +} + +type workType int + +const ( + workDiscover workType = iota // run discoverAndSync + workSync // run syncPlaylist for a single playlist +) + +type workItem struct { + typ workType + info capabilities.PlaylistInfo // only for workSync + dbID string // only for workSync + ownerID string // only for workSync +} + +// playlistSyncer manages playlist synchronization for a single plugin. +// All mutable state (refreshTimers, discoveryTimer) is owned exclusively by the +// worker goroutine — no synchronization needed. The retryInterval and +// refreshTimerCount fields use atomics so tests can observe them race-free. +type playlistSyncer struct { + pluginName string + plugin *plugin + ds model.DataStore + matcher *matcher.Matcher + ctx context.Context + cancel context.CancelFunc + workCh chan workItem // serialized work queue + refreshTimers map[string]*time.Timer // keyed by playlist DB ID — worker-only + discoveryTimer *time.Timer // worker-only + retryInterval atomic.Int64 // nanoseconds; from last GetAvailablePlaylists response + refreshTimerCount atomic.Int32 // number of active refresh timers + done chan struct{} // closed when worker exits +} + +func newPlaylistSyncer(parentCtx context.Context, pluginName string, p *plugin, ds model.DataStore, m *matcher.Matcher) *playlistSyncer { + ctx, cancel := context.WithCancel(parentCtx) + return &playlistSyncer{ + pluginName: pluginName, + plugin: p, + ds: ds, + matcher: m, + ctx: ctx, + cancel: cancel, + workCh: make(chan workItem, workChCapacity), + refreshTimers: make(map[string]*time.Timer), + done: make(chan struct{}), + } +} + +// run is the single worker goroutine that processes all work items sequentially. +// It performs an initial discovery before entering the main loop. +func (p *playlistSyncer) run() { + defer close(p.done) + + // Run initial discovery before entering the loop + p.discoverAndSync() + + for { + select { + case <-p.ctx.Done(): + p.stopAllTimers() + return + case item := <-p.workCh: + switch item.typ { + case workDiscover: + p.discoverAndSync() + case workSync: + p.syncPlaylist(item.info, item.dbID, item.ownerID) + } + } + } +} + +// discoverAndSync calls GetAvailablePlaylists, then GetPlaylist for each, matches tracks, and upserts. +func (p *playlistSyncer) discoverAndSync() { + ctx := p.ctx + resp, err := callPluginFunction[capabilities.GetAvailablePlaylistsRequest, capabilities.GetAvailablePlaylistsResponse]( + ctx, p.plugin, FuncPlaylistProviderGetAvailablePlaylists, capabilities.GetAvailablePlaylistsRequest{}, + ) + if err != nil { + log.Error(ctx, "Failed to call GetAvailablePlaylists, retrying later", "plugin", p.pluginName, err) + p.scheduleDiscovery(discoveryRetryDelay) + return + } + + // Store retry interval from response (including 0, which disables retries) + p.retryInterval.Store(int64(time.Duration(resp.RetryInterval) * time.Second)) + + resolvedUsers := map[string]string{} // username -> userID cache + for _, info := range resp.Playlists { + // Resolve username to user ID (cached) + ownerID, ok := resolvedUsers[info.OwnerUsername] + if !ok { + user, err := p.ds.User(adminContext(ctx)).FindByUsername(info.OwnerUsername) + if err != nil { + log.Error(ctx, "Failed to resolve playlist owner", "plugin", p.pluginName, + "playlistID", info.ID, "username", info.OwnerUsername, err) + continue + } + ownerID = user.ID + resolvedUsers[info.OwnerUsername] = ownerID + } + + // Validate that the plugin is permitted to create playlists for this user + if !p.plugin.allUsers && !slices.Contains(p.plugin.allowedUserIDs, ownerID) { + log.Error(ctx, "Plugin not permitted to create playlists for user", "plugin", p.pluginName, + "playlistID", info.ID, "username", info.OwnerUsername) + continue + } + + dbID := id.NewHash(p.pluginName, info.ID, ownerID) + p.syncPlaylist(info, dbID, ownerID) + } + + // Schedule re-discovery if RefreshInterval > 0, otherwise cancel any existing timer + if resp.RefreshInterval > 0 { + p.scheduleDiscovery(time.Duration(resp.RefreshInterval) * time.Second) + } else if p.discoveryTimer != nil { + p.discoveryTimer.Stop() + p.discoveryTimer = nil + } +} + +// syncPlaylist calls GetPlaylist, matches tracks, and upserts the playlist in the DB. +func (p *playlistSyncer) syncPlaylist(info capabilities.PlaylistInfo, dbID string, ownerID string) { + ctx := p.ctx + resp, err := callPluginFunction[capabilities.GetPlaylistRequest, capabilities.GetPlaylistResponse]( + ctx, p.plugin, FuncPlaylistProviderGetPlaylist, capabilities.GetPlaylistRequest{ID: info.ID}, + ) + if err != nil { + if isPlaylistNotFoundError(err) { + log.Info(ctx, "Playlist not found, skipping", "plugin", p.pluginName, "playlistID", info.ID) + p.cancelRefreshTimer(dbID) + return + } + log.Warn(ctx, "Failed to call GetPlaylist", "plugin", p.pluginName, "playlistID", info.ID, err) + // Schedule retry for transient errors if retryInterval is configured + if ri := time.Duration(p.retryInterval.Load()); ri > 0 { + p.schedulePlaylistRefresh(info, dbID, ownerID, ri) + } + return + } + + // Convert SongRef → agents.Song and match against library + songs := songRefsToAgentSongs(resp.Tracks) + matched, err := p.matcher.MatchSongsToLibrary(ctx, songs, len(songs)) + if err != nil { + log.Error(ctx, "Failed to match songs to library", "plugin", p.pluginName, "playlistID", info.ID, err) + return + } + + // Build playlist model + pls := &model.Playlist{ + ID: dbID, + Name: resp.Name, + Comment: resp.Description, + OwnerID: ownerID, + Public: false, + ExternalImageURL: resp.CoverArtURL, + PluginID: p.pluginName, + PluginPlaylistID: info.ID, + } + if resp.ValidUntil > 0 { + t := time.Unix(resp.ValidUntil, 0) + pls.ValidUntil = &t + } + + // Set tracks from matched media files + pls.AddMediaFiles(matched) + + // Upsert via repository + plsRepo := p.ds.Playlist(ctx) + if err := plsRepo.Put(pls); err != nil { + log.Error(ctx, "Failed to upsert plugin playlist", "plugin", p.pluginName, "playlistID", info.ID, err) + return + } + + log.Info(ctx, "Synced plugin playlist", "plugin", p.pluginName, "playlistID", info.ID, + "name", resp.Name, "tracks", len(matched), "owner", ownerID) + + // Schedule refresh if ValidUntil > 0, otherwise cancel any stale timer + if resp.ValidUntil > 0 { + validUntil := time.Unix(resp.ValidUntil, 0) + delay := time.Until(validUntil) + if delay <= 0 { + delay = 1 * time.Second // Already expired, refresh soon + } + p.schedulePlaylistRefresh(info, dbID, ownerID, delay) + } else { + p.cancelRefreshTimer(dbID) + } +} + +// cancelRefreshTimer stops and removes the refresh timer for the given playlist DB ID, if any. +func (p *playlistSyncer) cancelRefreshTimer(dbID string) { + if timer, ok := p.refreshTimers[dbID]; ok { + timer.Stop() + delete(p.refreshTimers, dbID) + p.refreshTimerCount.Store(int32(len(p.refreshTimers))) + } +} + +func (p *playlistSyncer) schedulePlaylistRefresh(info capabilities.PlaylistInfo, dbID string, ownerID string, delay time.Duration) { + // Cancel existing timer if any + if timer, ok := p.refreshTimers[dbID]; ok { + timer.Stop() + } + p.refreshTimers[dbID] = time.AfterFunc(delay, func() { + select { + case p.workCh <- workItem{typ: workSync, info: info, dbID: dbID, ownerID: ownerID}: + case <-p.ctx.Done(): + } + }) + p.refreshTimerCount.Store(int32(len(p.refreshTimers))) +} + +func (p *playlistSyncer) scheduleDiscovery(delay time.Duration) { + if p.discoveryTimer != nil { + p.discoveryTimer.Stop() + } + p.discoveryTimer = time.AfterFunc(delay, func() { + select { + case p.workCh <- workItem{typ: workDiscover}: + case <-p.ctx.Done(): + } + }) +} + +// isPlaylistNotFoundError checks if the error contains a NotFound sentinel from the plugin. +func isPlaylistNotFoundError(err error) bool { + return err != nil && strings.Contains(err.Error(), capabilities.PlaylistProviderErrorNotFound.Error()) +} + +// stopAllTimers stops the discovery timer and all refresh timers. +func (p *playlistSyncer) stopAllTimers() { + if p.discoveryTimer != nil { + p.discoveryTimer.Stop() + } + for _, timer := range p.refreshTimers { + timer.Stop() + } +} + +// Close cancels the context and waits for the worker goroutine to finish. +func (p *playlistSyncer) Close() error { + p.cancel() + <-p.done + return nil +} diff --git a/plugins/playlist_provider_test.go b/plugins/playlist_provider_test.go new file mode 100644 index 000000000..c11c7b6c9 --- /dev/null +++ b/plugins/playlist_provider_test.go @@ -0,0 +1,226 @@ +//go:build !windows + +package plugins + +import ( + "time" + + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// findSyncer finds the playlistSyncer in a plugin's closers. +func findSyncer(m *Manager, pluginName string) *playlistSyncer { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.plugins[pluginName] + if !ok { + return nil + } + for _, c := range p.closers { + if syncer, ok := c.(*playlistSyncer); ok { + return syncer + } + } + return nil +} + +var _ = Describe("PlaylistProvider", Ordered, func() { + var ( + pgManager *Manager + mockPlsRepo *tests.MockPlaylistRepo + ) + + BeforeAll(func() { + pgManager, _ = createTestManagerWithPlugins(nil, + "test-playlist-provider"+PackageExtension, + ) + + mockPlsRepo = pgManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo) + }) + + Describe("capability detection", func() { + It("detects the PlaylistProvider capability", func() { + names := pgManager.PluginNames(string(CapabilityPlaylistProvider)) + Expect(names).To(ContainElement("test-playlist-provider")) + }) + }) + + Describe("syncer lifecycle", func() { + It("creates a syncer for the plugin", func() { + Expect(findSyncer(pgManager, "test-playlist-provider")).ToNot(BeNil()) + }) + + It("discovers and syncs playlists from the plugin", func() { + // The syncer runs discoverAndSync in a goroutine on Start(). + // Give it a moment to complete. + Eventually(func() int { + return mockPlsRepo.Len() + }).Should(BeNumerically(">=", 2)) + }) + + It("creates playlists with correct fields", func() { + Eventually(func() bool { + return mockPlsRepo.FindByPluginPlaylistID("daily-mix-1") != nil + }).Should(BeTrue()) + + dailyMix1 := mockPlsRepo.FindByPluginPlaylistID("daily-mix-1") + Expect(dailyMix1.Name).To(Equal("Daily Mix 1")) + Expect(dailyMix1.Comment).To(Equal("Your personalized daily mix")) + Expect(dailyMix1.ExternalImageURL).To(Equal("https://example.com/cover1.jpg")) + Expect(dailyMix1.OwnerID).To(Equal("user-1")) + Expect(dailyMix1.PluginID).To(Equal("test-playlist-provider")) + Expect(dailyMix1.PluginPlaylistID).To(Equal("daily-mix-1")) + Expect(dailyMix1.Public).To(BeFalse()) + Expect(dailyMix1.ValidUntil).To(BeNil()) + }) + + It("generates deterministic playlist IDs", func() { + expectedID := id.NewHash("test-playlist-provider", "daily-mix-1", "user-1") + Eventually(func() bool { + _, exists := mockPlsRepo.GetData(expectedID) + return exists + }).Should(BeTrue()) + }) + + It("creates distinct IDs for different playlists", func() { + id1 := id.NewHash("test-playlist-provider", "daily-mix-1", "user-1") + id2 := id.NewHash("test-playlist-provider", "daily-mix-2", "user-1") + Expect(id1).ToNot(Equal(id2)) + + Eventually(func() bool { + _, exists1 := mockPlsRepo.GetData(id1) + _, exists2 := mockPlsRepo.GetData(id2) + return exists1 && exists2 + }).Should(BeTrue()) + }) + }) + + Describe("GetAvailablePlaylists error handling", func() { + It("handles plugin errors gracefully", func() { + errManager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-playlist-provider": {"error": "service unavailable"}, + }, "test-playlist-provider"+PackageExtension) + + // Should still have the syncer (error is logged, not fatal) + Expect(findSyncer(errManager, "test-playlist-provider")).ToNot(BeNil()) + + // But no playlists created + errPlsRepo := errManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo) + // The syncer was started but GetAvailablePlaylists returned error, + // so no playlists should be created + Consistently(func() int { + return errPlsRepo.Len() + }, "500ms").Should(Equal(0)) + }) + }) + + Describe("GetPlaylist NotFound error", func() { + It("skips playlists when plugin returns NotFound", func() { + notFoundManager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-playlist-provider": { + "get_playlist_error": "playlist temporarily unavailable", + "get_playlist_error_type": string(capabilities.PlaylistProviderErrorNotFound), + }, + }, "test-playlist-provider"+PackageExtension) + + // Should still have the syncer + Expect(findSyncer(notFoundManager, "test-playlist-provider")).ToNot(BeNil()) + + // No playlists should be created (all returned NotFound) + notFoundPlsRepo := notFoundManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo) + Consistently(func() int { + return notFoundPlsRepo.Len() + }, "500ms").Should(Equal(0)) + + // No refresh timers should be scheduled for NotFound playlists + syncer := findSyncer(notFoundManager, "test-playlist-provider") + Eventually(func() int32 { + return syncer.refreshTimerCount.Load() + }).Should(Equal(int32(0))) + }) + }) + + Describe("GetPlaylist transient error with RetryInterval", func() { + It("stores retryInterval and schedules retry on transient errors", func() { + retryManager, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-playlist-provider": { + "get_playlist_error": "temporary failure", + "retry_interval": "60", + }, + }, "test-playlist-provider"+PackageExtension) + + syncer := findSyncer(retryManager, "test-playlist-provider") + Expect(syncer).ToNot(BeNil()) + + // retryInterval should be stored from the response + Eventually(func() time.Duration { + return time.Duration(syncer.retryInterval.Load()) + }).Should(Equal(60 * time.Second)) + + // No playlists should be created (GetPlaylist failed) + retryPlsRepo := retryManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo) + Consistently(func() int { + return retryPlsRepo.Len() + }, "500ms").Should(Equal(0)) + + // Refresh timers should be scheduled for transient errors + Eventually(func() int32 { + return syncer.refreshTimerCount.Load() + }).Should(BeNumerically(">=", int32(1))) + }) + }) + + Describe("user permission validation", func() { + It("skips playlists for unauthorized users when AllUsers is false", func() { + // Create manager with restricted users — only "other-user" is allowed, + // but the plugin returns playlists for "admin" which resolves to "user-1" + restrictedManager, _ := createTestManagerWithPluginOverrides(nil, + map[string]pluginOverride{ + "test-playlist-provider": {AllUsers: false, Users: `["other-user"]`}, + }, + "test-playlist-provider"+PackageExtension, + ) + + // No playlists should be created because "user-1" is not in allowed users + restrictedPlsRepo := restrictedManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo) + Consistently(func() int { + return restrictedPlsRepo.Len() + }, "500ms").Should(Equal(0)) + }) + + It("creates playlists for authorized users when AllUsers is false", func() { + // Create manager with restricted users — "user-1" is allowed, + // and the plugin returns playlists for "admin" which resolves to "user-1" + allowedManager, _ := createTestManagerWithPluginOverrides(nil, + map[string]pluginOverride{ + "test-playlist-provider": {AllUsers: false, Users: `["user-1"]`}, + }, + "test-playlist-provider"+PackageExtension, + ) + + // Playlists should be created because "user-1" is in allowed users + allowedPlsRepo := allowedManager.ds.(*tests.MockDataStore).MockedPlaylist.(*tests.MockPlaylistRepo) + Eventually(func() int { + return allowedPlsRepo.Len() + }).Should(BeNumerically(">=", 2)) + }) + }) + + Describe("stop", func() { + It("stops the syncer when the manager stops", func() { + stopManager, _ := createTestManagerWithPlugins(nil, + "test-playlist-provider"+PackageExtension, + ) + Expect(findSyncer(stopManager, "test-playlist-provider")).ToNot(BeNil()) + + err := stopManager.Stop() + Expect(err).ToNot(HaveOccurred()) + // After Stop(), the plugin is unloaded so findSyncer returns nil + Expect(findSyncer(stopManager, "test-playlist-provider")).To(BeNil()) + }) + }) +}) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 1799ba3ce..7f7d95f05 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -81,10 +82,26 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...) } +// pluginOverride allows tests to override model.Plugin fields for specific plugins. +type pluginOverride struct { + AllUsers bool + Users string // JSON array of user IDs, e.g. `["user-1"]` +} + +// createTestManagerWithPluginOverrides creates a new plugin Manager with the given plugin config, +// per-plugin overrides, and specified plugins. +func createTestManagerWithPluginOverrides(pluginConfig map[string]map[string]string, overrides map[string]pluginOverride, plugins ...string) (*Manager, string) { + return createTestManagerFull(pluginConfig, overrides, noopMetricsRecorder{}, plugins...) +} + // createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config, // metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager. // Returns the manager and temp directory path. func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) { + return createTestManagerFull(pluginConfig, nil, metrics, plugins...) +} + +func createTestManagerFull(pluginConfig map[string]map[string]string, overrides map[string]pluginOverride, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) { // Create temp directory tmpDir, err := os.MkdirTemp("", "plugins-test-*") Expect(err).ToNot(HaveOccurred()) @@ -113,14 +130,21 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s configJSON = string(configBytes) } - enabledPlugins = append(enabledPlugins, model.Plugin{ + p := model.Plugin{ ID: pluginName, Path: destPath, SHA256: hashHex, Enabled: true, Config: configJSON, AllUsers: true, // Allow all users by default in tests - }) + } + if overrides != nil { + if o, ok := overrides[pluginName]; ok { + p.AllUsers = o.AllUsers + p.Users = o.Users + } + } + enabledPlugins = append(enabledPlugins, p) } // Setup config @@ -133,12 +157,19 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s mockPluginRepo := tests.CreateMockPluginRepo() mockPluginRepo.Permitted = true mockPluginRepo.SetData(enabledPlugins) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} + + // Pre-seed a mock user repo with a default user so that + // PlaylistProvider's discoverAndSync can resolve usernames. + mockUserRepo := tests.CreateMockUserRepo() + _ = mockUserRepo.Put(&model.User{ID: "user-1", UserName: "admin"}) + + dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedUser: mockUserRepo, MockedPlaylist: tests.CreateMockPlaylistRepo()} // Create and start manager manager := &Manager{ plugins: make(map[string]*plugin), ds: dataStore, + matcher: matcher.New(dataStore), metrics: metrics, subsonicRouter: http.NotFoundHandler(), // Stub router for tests } diff --git a/plugins/testdata/test-playlist-provider/go.mod b/plugins/testdata/test-playlist-provider/go.mod new file mode 100644 index 000000000..b09064953 --- /dev/null +++ b/plugins/testdata/test-playlist-provider/go.mod @@ -0,0 +1,16 @@ +module test-playlist-provider + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-playlist-provider/go.sum b/plugins/testdata/test-playlist-provider/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-playlist-provider/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-playlist-provider/main.go b/plugins/testdata/test-playlist-provider/main.go new file mode 100644 index 000000000..41def98aa --- /dev/null +++ b/plugins/testdata/test-playlist-provider/main.go @@ -0,0 +1,86 @@ +// Test playlist provider plugin for Navidrome plugin system integration tests. +package main + +import ( + "fmt" + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + pp "github.com/navidrome/navidrome/plugins/pdk/go/playlistprovider" +) + +func init() { + pp.Register(&testPlaylistProvider{}) +} + +type testPlaylistProvider struct{} + +func (t *testPlaylistProvider) GetAvailablePlaylists(_ pp.GetAvailablePlaylistsRequest) (pp.GetAvailablePlaylistsResponse, error) { + // Check for configured error + errMsg, hasErr := pdk.GetConfig("error") + if hasErr && errMsg != "" { + return pp.GetAvailablePlaylistsResponse{}, fmt.Errorf("%s", errMsg) + } + + // Get the owner username from config (defaults to "admin") + ownerUsername := "admin" + if u, ok := pdk.GetConfig("owner_username"); ok && u != "" { + ownerUsername = u + } + + resp := pp.GetAvailablePlaylistsResponse{ + Playlists: []pp.PlaylistInfo{ + {ID: "daily-mix-1", OwnerUsername: ownerUsername}, + {ID: "daily-mix-2", OwnerUsername: ownerUsername}, + }, + RefreshInterval: 0, // No re-discovery in tests + } + + // Support configurable retry interval + if ri, ok := pdk.GetConfig("retry_interval"); ok && ri != "" { + if v, err := strconv.ParseInt(ri, 10, 64); err == nil { + resp.RetryInterval = v + } + } + + return resp, nil +} + +func (t *testPlaylistProvider) GetPlaylist(req pp.GetPlaylistRequest) (pp.GetPlaylistResponse, error) { + // Check for configured error + errMsg, hasErr := pdk.GetConfig("get_playlist_error") + if hasErr && errMsg != "" { + // Check if the error should be typed (e.g., NotFound) + errType, _ := pdk.GetConfig("get_playlist_error_type") + if errType == pp.PlaylistProviderErrorNotFound.Error() { + return pp.GetPlaylistResponse{}, fmt.Errorf("%w: %s", pp.PlaylistProviderErrorNotFound, errMsg) + } + return pp.GetPlaylistResponse{}, fmt.Errorf("%s", errMsg) + } + + switch req.ID { + case "daily-mix-1": + return pp.GetPlaylistResponse{ + Name: "Daily Mix 1", + Description: "Your personalized daily mix", + CoverArtURL: "https://example.com/cover1.jpg", + Tracks: []pp.SongRef{ + {Name: "Song A", Artist: "Artist One"}, + {Name: "Song B", Artist: "Artist Two"}, + }, + ValidUntil: 0, // Static, no refresh + }, nil + case "daily-mix-2": + return pp.GetPlaylistResponse{ + Name: "Daily Mix 2", + Tracks: []pp.SongRef{ + {Name: "Song C", Artist: "Artist Three"}, + }, + ValidUntil: 0, + }, nil + default: + return pp.GetPlaylistResponse{}, fmt.Errorf("unknown playlist: %s", req.ID) + } +} + +func main() {} diff --git a/plugins/testdata/test-playlist-provider/manifest.json b/plugins/testdata/test-playlist-provider/manifest.json new file mode 100644 index 000000000..7efec1b2a --- /dev/null +++ b/plugins/testdata/test-playlist-provider/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Test Playlist Provider", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test playlist provider plugin for integration testing", + "permissions": { + "users": { + "reason": "Required for playlist ownership" + } + } +} diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index a8c3da68c..92576a66d 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -165,11 +165,13 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso } pls := responses.OpenSubsonicPlaylist{} - if p.IsSmartPlaylist() { + if p.IsReadOnly() { pls.Readonly = true - if p.EvaluatedAt != nil { + if p.IsSmartPlaylist() && p.EvaluatedAt != nil { pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + } else if p.IsPluginPlaylist() && p.ValidUntil != nil { + pls.ValidUntil = p.ValidUntil } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 3f2a2068e..a9fd29b40 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -156,6 +156,51 @@ var _ = Describe("buildPlaylist", func() { }) }) + Describe("plugin playlist", func() { + BeforeEach(func() { + createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) + updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) + + playlist = model.Playlist{ + ID: "pls-plugin", + Name: "Daily Mix", + Comment: "Generated by plugin", + OwnerName: "admin", + OwnerID: "1234", + Public: false, + SongCount: 5, + Duration: 300, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + PluginID: "test-plugin", + PluginPlaylistID: "daily-mix", + } + }) + + It("marks plugin playlist as readonly with no ValidUntil when not set", func() { + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + result := router.buildPlaylist(ctx, playlist) + Expect(result.Readonly).To(BeTrue()) + Expect(result.ValidUntil).To(BeNil()) + }) + + It("exposes ValidUntil when set on the model", func() { + validUntil := time.Date(2023, 3, 1, 12, 0, 0, 0, time.UTC) + playlist.ValidUntil = &validUntil + + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + result := router.buildPlaylist(ctx, playlist) + Expect(result.Readonly).To(BeTrue()) + Expect(result.ValidUntil).To(Equal(&validUntil)) + }) + + It("marks plugin playlist as readonly even for non-owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "other-user", UserName: "other"}) + result := router.buildPlaylist(ctx, playlist) + Expect(result.Readonly).To(BeTrue()) + }) + }) + Describe("smart playlist", func() { evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC) validUntil := evaluatedAt.Add(5 * time.Second) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9bdc52152..0fa3b0eae 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -2,6 +2,7 @@ package tests import ( "errors" + "sync" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -17,6 +18,7 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo { type MockPlaylistRepo struct { model.PlaylistRepository + mu sync.RWMutex Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path Last *model.Playlist @@ -26,10 +28,14 @@ type MockPlaylistRepo struct { } func (m *MockPlaylistRepo) SetError(err bool) { + m.mu.Lock() + defer m.mu.Unlock() m.Err = err } func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { + m.mu.RLock() + defer m.mu.RUnlock() if m.Err { return nil, errors.New("error") } @@ -46,6 +52,8 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, } func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err { return errors.New("error") } @@ -60,6 +68,8 @@ func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { } func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { + m.mu.RLock() + defer m.mu.RUnlock() if m.Err { return nil, errors.New("error") } @@ -72,6 +82,8 @@ func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { } func (m *MockPlaylistRepo) Delete(id string) error { + m.mu.Lock() + defer m.mu.Unlock() if m.Err { return errors.New("error") } @@ -80,10 +92,14 @@ func (m *MockPlaylistRepo) Delete(id string) error { } func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { + m.mu.RLock() + defer m.mu.RUnlock() return m.TracksRepo } func (m *MockPlaylistRepo) Exists(id string) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() if m.Err { return false, errors.New("error") } @@ -95,6 +111,8 @@ func (m *MockPlaylistRepo) Exists(id string) (bool, error) { } func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { + m.mu.RLock() + defer m.mu.RUnlock() if m.Err { return 0, errors.New("error") } @@ -102,10 +120,39 @@ func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { } func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + m.mu.RLock() + defer m.mu.RUnlock() if m.Err { return 0, errors.New("error") } return int64(len(m.Data)), nil } +// Len returns the number of playlists in the repo in a thread-safe manner. +func (m *MockPlaylistRepo) Len() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.Data) +} + +// GetData returns a playlist by ID in a thread-safe manner. +func (m *MockPlaylistRepo) GetData(id string) (*model.Playlist, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + pls, ok := m.Data[id] + return pls, ok +} + +// FindByPluginPlaylistID returns the first playlist with the given plugin playlist ID. +func (m *MockPlaylistRepo) FindByPluginPlaylistID(pluginPlaylistID string) *model.Playlist { + m.mu.RLock() + defer m.mu.RUnlock() + for _, pls := range m.Data { + if pls.PluginPlaylistID == pluginPlaylistID { + return pls + } + } + return nil +} + var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil) diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js index 74a01d47a..eef7c680c 100644 --- a/ui/src/common/playlistUtils.js +++ b/ui/src/common/playlistUtils.js @@ -11,5 +11,7 @@ export const isReadOnly = (ownerId) => { export const isSmartPlaylist = (pls) => !!pls.rules +export const isPluginPlaylist = (pls) => !!pls.pluginId + export const canChangeTracks = (pls) => - isWritable(pls.ownerId) && !isSmartPlaylist(pls) + isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !isPluginPlaylist(pls) diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js index 2c671ecf5..f40bfaea7 100644 --- a/ui/src/common/playlistUtils.test.js +++ b/ui/src/common/playlistUtils.test.js @@ -2,6 +2,7 @@ import { isWritable, isReadOnly, isSmartPlaylist, + isPluginPlaylist, canChangeTracks, } from './playlistUtils' @@ -56,6 +57,18 @@ describe('playlistUtils', () => { }) }) + describe('isPluginPlaylist', () => { + it('returns true if playlist has pluginId', () => { + const playlist = { pluginId: 'test-plugin' } + expect(isPluginPlaylist(playlist)).toBe(true) + }) + + it('returns false if playlist does not have pluginId', () => { + const playlist = {} + expect(isPluginPlaylist(playlist)).toBe(false) + }) + }) + describe('canChangeTracks', () => { it('returns true if user is the owner and playlist is not smart', () => { localStorage.setItem('userId', 'user1') @@ -74,5 +87,11 @@ describe('playlistUtils', () => { const playlist = { ownerId: 'user1', rules: [] } expect(canChangeTracks(playlist)).toBe(false) }) + + it('returns false if playlist is a plugin playlist', () => { + localStorage.setItem('userId', 'user1') + const playlist = { ownerId: 'user1', pluginId: 'test-plugin' } + expect(canChangeTracks(playlist)).toBe(false) + }) }) }) diff --git a/ui/src/dialogs/AddToPlaylistDialog.test.jsx b/ui/src/dialogs/AddToPlaylistDialog.test.jsx index 60d3cca0d..8735ad885 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.test.jsx +++ b/ui/src/dialogs/AddToPlaylistDialog.test.jsx @@ -46,7 +46,7 @@ const createTestUtils = (mockDataProvider) => data: mockIndexedData, list: { cachedRequests: { - '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}': + '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"readonly":false}}': { ids: ['sample-id1', 'sample-id2'], total: 2, diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 847107523..bc07ec0e4 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -264,7 +264,7 @@ export const SelectPlaylistInput = ({ onChange }) => { 'playlist', { page: 1, perPage: -1 }, { field: 'name', order: 'ASC' }, - { smart: false }, + { readonly: false }, ) const options = diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 4ffcdf0b6..44bb803ed 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -53,7 +53,7 @@ const createTestComponent = ( data: indexedData, list: { cachedRequests: { - '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}': + '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"readonly":false}}': { ids: Object.keys(indexedData), total: Object.keys(indexedData).length, diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx index f6882e366..181d23114 100644 --- a/ui/src/playlist/PlaylistEdit.jsx +++ b/ui/src/playlist/PlaylistEdit.jsx @@ -11,7 +11,7 @@ import { ReferenceInput, SelectInput, } from 'react-admin' -import { isWritable, Title } from '../common' +import { isWritable, isPluginPlaylist, Title } from '../common' const SyncFragment = ({ formData, variant, ...rest }) => { return ( @@ -33,12 +33,17 @@ const PlaylistEditForm = (props) => { const { permissions } = usePermissions() return ( - + { ) : null }