mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge 439cff47ac3d5d32487a9f81b82a3f4a78756a76 into 5d1c1157b5bde16c2b0ff6017bfe4a20bdbb6e7c
This commit is contained in:
commit
393fd09f7b
@ -70,9 +70,9 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
@ -92,9 +92,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
@ -121,9 +121,9 @@ func CreatePublicRouter() *public.Router {
|
|||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
@ -169,9 +169,9 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
@ -188,9 +188,9 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
@ -213,7 +213,8 @@ func getPluginManager() *plugins.Manager {
|
|||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -324,7 +324,7 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
|
|||||||
// collectPlugins collects information about installed plugins
|
// collectPlugins collects information about installed plugins
|
||||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||||
// TODO Fix import/inject cycles
|
// 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()
|
info := manager.GetPluginInfo()
|
||||||
|
|
||||||
result := make(map[string]insights.PluginInfo, len(info))
|
result := make(map[string]insights.PluginInfo, len(info))
|
||||||
|
|||||||
@ -113,7 +113,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if pls.IsSmartPlaylist() {
|
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
|
||||||
return model.ErrNotAuthorized
|
return model.ErrNotAuthorized
|
||||||
}
|
}
|
||||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||||
@ -163,6 +163,12 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||||
repo := tx.Playlist(ctx)
|
repo := tx.Playlist(ctx)
|
||||||
|
|
||||||
@ -213,13 +219,13 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli
|
|||||||
return pls, nil
|
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) {
|
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
||||||
pls, err := s.checkWritable(ctx, playlistID)
|
pls, err := s.checkWritable(ctx, playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if pls.IsSmartPlaylist() {
|
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
|
||||||
return nil, model.ErrNotAuthorized
|
return nil, model.ErrNotAuthorized
|
||||||
}
|
}
|
||||||
return pls, nil
|
return pls, nil
|
||||||
|
|||||||
@ -80,6 +80,8 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
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())
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
@ -125,6 +127,12 @@ var _ = Describe("Playlists", func() {
|
|||||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
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() {
|
Describe("Update", func() {
|
||||||
@ -137,6 +145,8 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
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
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
@ -182,12 +192,45 @@ var _ = Describe("Playlists", func() {
|
|||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
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() {
|
It("allows metadata updates on a smart playlist", func() {
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||||
newName := "Updated Smart"
|
newName := "Updated Smart"
|
||||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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() {
|
Describe("AddTracks", func() {
|
||||||
@ -199,6 +242,8 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
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"},
|
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||||
}
|
}
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
@ -232,6 +277,12 @@ var _ = Describe("Playlists", func() {
|
|||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
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() {
|
It("returns error when playlist not found", func() {
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||||
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
_, 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-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
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
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
@ -266,6 +319,12 @@ var _ = Describe("Playlists", func() {
|
|||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
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() {
|
It("denies non-owner", func() {
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
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-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
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
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
@ -299,6 +360,12 @@ var _ = Describe("Playlists", func() {
|
|||||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
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() {
|
Describe("SetImage", func() {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package playlists
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/model"
|
"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.UploadedImage = "" // Managed by image upload endpoint
|
||||||
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
|
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
|
||||||
pls.EvaluatedAt = nil // Server-managed
|
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)
|
err := s.ds.Playlist(ctx).Put(pls)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||||
return rest.ErrPermissionDenied
|
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)
|
// Apply ownership change (admin only)
|
||||||
if entity.OwnerID != "" {
|
if entity.OwnerID != "" {
|
||||||
current.OwnerID = entity.OwnerID
|
current.OwnerID = entity.OwnerID
|
||||||
|
|||||||
@ -74,6 +74,9 @@ var _ = Describe("REST Adapter", func() {
|
|||||||
UploadedImage: "injected-image-path",
|
UploadedImage: "injected-image-path",
|
||||||
ExternalImageURL: "http://evil.example.com/ssrf",
|
ExternalImageURL: "http://evil.example.com/ssrf",
|
||||||
EvaluatedAt: &now,
|
EvaluatedAt: &now,
|
||||||
|
PluginID: "fake-plugin",
|
||||||
|
PluginPlaylistID: "fake-playlist-id",
|
||||||
|
ValidUntil: &now,
|
||||||
}
|
}
|
||||||
_, err := repo.Save(pls)
|
_, err := repo.Save(pls)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -90,6 +93,9 @@ var _ = Describe("REST Adapter", func() {
|
|||||||
Expect(saved.UploadedImage).To(BeEmpty())
|
Expect(saved.UploadedImage).To(BeEmpty())
|
||||||
Expect(saved.ExternalImageURL).To(BeEmpty())
|
Expect(saved.ExternalImageURL).To(BeEmpty())
|
||||||
Expect(saved.EvaluatedAt).To(BeNil())
|
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())
|
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() {
|
It("allows admin to update any playlist", func() {
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||||
|
|||||||
32
db/migrations/20260305051806_add_plugin_playlist_fields.go
Normal file
32
db/migrations/20260305051806_add_plugin_playlist_fields.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -30,12 +30,25 @@ type Playlist struct {
|
|||||||
// SmartPlaylist attributes
|
// SmartPlaylist attributes
|
||||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||||
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
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 {
|
func (pls Playlist) IsSmartPlaylist() bool {
|
||||||
return pls.Rules != nil && pls.Rules.Expression != nil
|
return pls.Rules != nil && pls.Rules.Expression != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pls Playlist) IsPluginPlaylist() bool {
|
||||||
|
return pls.PluginID != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (pls Playlist) MediaFiles() MediaFiles {
|
func (pls Playlist) MediaFiles() MediaFiles {
|
||||||
if len(pls.Tracks) == 0 {
|
if len(pls.Tracks) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -50,8 +50,9 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
|||||||
r.ctx = ctx
|
r.ctx = ctx
|
||||||
r.db = db
|
r.db = db
|
||||||
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
||||||
"q": playlistFilter,
|
"q": playlistFilter,
|
||||||
"smart": smartPlaylistFilter,
|
"smart": smartPlaylistFilter,
|
||||||
|
"readonly": readonlyPlaylistFilter,
|
||||||
})
|
})
|
||||||
r.setSortMappings(map[string]string{
|
r.setSortMappings(map[string]string{
|
||||||
"owner_name": "owner_name",
|
"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 {
|
func (r *playlistRepository) userFilter() Sqlizer {
|
||||||
user := loggedUser(r.ctx)
|
user := loggedUser(r.ctx)
|
||||||
if user.IsAdmin {
|
if user.IsAdmin {
|
||||||
|
|||||||
72
plugins/capabilities/playlist_provider.go
Normal file
72
plugins/capabilities/playlist_provider.go
Normal file
@ -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) }
|
||||||
127
plugins/capabilities/playlist_provider.yaml
Normal file
127
plugins/capabilities/playlist_provider.yaml
Normal file
@ -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
|
||||||
@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
|
"github.com/navidrome/navidrome/core/matcher"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@ -66,16 +67,18 @@ type Manager struct {
|
|||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
metrics PluginMetricsRecorder
|
metrics PluginMetricsRecorder
|
||||||
|
matcher *matcher.Matcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetManager returns a singleton instance of the plugin manager.
|
// GetManager returns a singleton instance of the plugin manager.
|
||||||
// The manager is not started automatically; call Start() to begin loading plugins.
|
// 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 singleton.GetInstance(func() *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
broker: broker,
|
broker: broker,
|
||||||
metrics: m,
|
metrics: m,
|
||||||
|
matcher: mt,
|
||||||
plugins: make(map[string]*plugin),
|
plugins: make(map[string]*plugin),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -373,8 +373,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|||||||
return fmt.Errorf("manifest validation: %w", err)
|
return fmt.Errorf("manifest validation: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
loadedPlugin := &plugin{
|
||||||
m.plugins[p.ID] = &plugin{
|
|
||||||
name: p.ID,
|
name: p.ID,
|
||||||
path: p.Path,
|
path: p.Path,
|
||||||
manifest: pkg.Manifest,
|
manifest: pkg.Manifest,
|
||||||
@ -386,10 +385,19 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
|||||||
allUsers: p.AllUsers,
|
allUsers: p.AllUsers,
|
||||||
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.plugins[p.ID] = loadedPlugin
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// Call plugin init function
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
// Scheduler permission requires SchedulerCallback capability
|
||||||
if m.Permissions != nil && m.Permissions.Scheduler != nil {
|
if m.Permissions != nil && m.Permissions.Scheduler != nil {
|
||||||
if !hasCapability(capabilities, CapabilityScheduler) {
|
if !hasCapability(capabilities, CapabilityScheduler) {
|
||||||
|
|||||||
@ -463,5 +463,48 @@ var _ = Describe("Manifest", func() {
|
|||||||
err := ValidateWithCapabilities(m, []Capability{})
|
err := ValidateWithCapabilities(m, []Capability{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
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"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
171
plugins/pdk/go/playlistprovider/playlistprovider.go
Normal file
171
plugins/pdk/go/playlistprovider/playlistprovider.go
Normal file
@ -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
|
||||||
|
}
|
||||||
106
plugins/pdk/go/playlistprovider/playlistprovider_stub.go
Normal file
106
plugins/pdk/go/playlistprovider/playlistprovider_stub.go
Normal file
@ -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) {}
|
||||||
@ -8,6 +8,7 @@
|
|||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
pub mod lyrics;
|
pub mod lyrics;
|
||||||
pub mod metadata;
|
pub mod metadata;
|
||||||
|
pub mod playlistprovider;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod scrobbler;
|
pub mod scrobbler;
|
||||||
pub mod taskworker;
|
pub mod taskworker;
|
||||||
|
|||||||
173
plugins/pdk/rust/nd-pdk-capabilities/src/playlistprovider.rs
Normal file
173
plugins/pdk/rust/nd-pdk-capabilities/src/playlistprovider.rs
Normal file
@ -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<PlaylistInfo>,
|
||||||
|
/// 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<SongRef>,
|
||||||
|
/// 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<String>) -> 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<GetAvailablePlaylistsResponse, Error>;
|
||||||
|
/// GetPlaylist - GetPlaylist returns the full data for a single playlist (tracks, metadata).
|
||||||
|
fn get_playlist(&self, req: GetPlaylistRequest) -> Result<GetPlaylistResponse, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<extism_pdk::Json<$crate::playlistprovider::GetAvailablePlaylistsResponse>> {
|
||||||
|
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<extism_pdk::Json<$crate::playlistprovider::GetPlaylistResponse>> {
|
||||||
|
let plugin = <$plugin_type>::default();
|
||||||
|
let result = $crate::playlistprovider::PlaylistProvider::get_playlist(&plugin, req.into_inner())?;
|
||||||
|
Ok(extism_pdk::Json(result))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
285
plugins/playlist_provider.go
Normal file
285
plugins/playlist_provider.go
Normal file
@ -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
|
||||||
|
}
|
||||||
226
plugins/playlist_provider_test.go
Normal file
226
plugins/playlist_provider_test.go
Normal file
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/matcher"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
@ -81,10 +82,26 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
|
|||||||
return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...)
|
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,
|
// 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.
|
// 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.
|
// Returns the manager and temp directory path.
|
||||||
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
|
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
|
// Create temp directory
|
||||||
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
|
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
@ -113,14 +130,21 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
|
|||||||
configJSON = string(configBytes)
|
configJSON = string(configBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledPlugins = append(enabledPlugins, model.Plugin{
|
p := model.Plugin{
|
||||||
ID: pluginName,
|
ID: pluginName,
|
||||||
Path: destPath,
|
Path: destPath,
|
||||||
SHA256: hashHex,
|
SHA256: hashHex,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Config: configJSON,
|
Config: configJSON,
|
||||||
AllUsers: true, // Allow all users by default in tests
|
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
|
// Setup config
|
||||||
@ -133,12 +157,19 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
|
|||||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||||
mockPluginRepo.Permitted = true
|
mockPluginRepo.Permitted = true
|
||||||
mockPluginRepo.SetData(enabledPlugins)
|
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
|
// Create and start manager
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
plugins: make(map[string]*plugin),
|
plugins: make(map[string]*plugin),
|
||||||
ds: dataStore,
|
ds: dataStore,
|
||||||
|
matcher: matcher.New(dataStore),
|
||||||
metrics: metrics,
|
metrics: metrics,
|
||||||
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
|
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
|
||||||
}
|
}
|
||||||
|
|||||||
16
plugins/testdata/test-playlist-provider/go.mod
vendored
Normal file
16
plugins/testdata/test-playlist-provider/go.mod
vendored
Normal file
@ -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
|
||||||
14
plugins/testdata/test-playlist-provider/go.sum
vendored
Normal file
14
plugins/testdata/test-playlist-provider/go.sum
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||||
|
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
86
plugins/testdata/test-playlist-provider/main.go
vendored
Normal file
86
plugins/testdata/test-playlist-provider/main.go
vendored
Normal file
@ -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() {}
|
||||||
11
plugins/testdata/test-playlist-provider/manifest.json
vendored
Normal file
11
plugins/testdata/test-playlist-provider/manifest.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -165,11 +165,13 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso
|
|||||||
}
|
}
|
||||||
pls := responses.OpenSubsonicPlaylist{}
|
pls := responses.OpenSubsonicPlaylist{}
|
||||||
|
|
||||||
if p.IsSmartPlaylist() {
|
if p.IsReadOnly() {
|
||||||
pls.Readonly = true
|
pls.Readonly = true
|
||||||
|
|
||||||
if p.EvaluatedAt != nil {
|
if p.IsSmartPlaylist() && p.EvaluatedAt != nil {
|
||||||
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
|
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
|
||||||
|
} else if p.IsPluginPlaylist() && p.ValidUntil != nil {
|
||||||
|
pls.ValidUntil = p.ValidUntil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user, ok := request.UserFrom(ctx)
|
user, ok := request.UserFrom(ctx)
|
||||||
|
|||||||
@ -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() {
|
Describe("smart playlist", func() {
|
||||||
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
|
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
|
||||||
validUntil := evaluatedAt.Add(5 * time.Second)
|
validUntil := evaluatedAt.Add(5 * time.Second)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package tests
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@ -17,6 +18,7 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo {
|
|||||||
|
|
||||||
type MockPlaylistRepo struct {
|
type MockPlaylistRepo struct {
|
||||||
model.PlaylistRepository
|
model.PlaylistRepository
|
||||||
|
mu sync.RWMutex
|
||||||
Data map[string]*model.Playlist // keyed by ID
|
Data map[string]*model.Playlist // keyed by ID
|
||||||
PathMap map[string]*model.Playlist // keyed by path
|
PathMap map[string]*model.Playlist // keyed by path
|
||||||
Last *model.Playlist
|
Last *model.Playlist
|
||||||
@ -26,10 +28,14 @@ type MockPlaylistRepo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPlaylistRepo) SetError(err bool) {
|
func (m *MockPlaylistRepo) SetError(err bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
m.Err = err
|
m.Err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) {
|
func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return nil, errors.New("error")
|
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 {
|
func (m *MockPlaylistRepo) Put(pls *model.Playlist) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return errors.New("error")
|
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) {
|
func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return nil, errors.New("error")
|
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 {
|
func (m *MockPlaylistRepo) Delete(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
@ -80,10 +92,14 @@ func (m *MockPlaylistRepo) Delete(id string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
|
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
return m.TracksRepo
|
return m.TracksRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPlaylistRepo) Exists(id string) (bool, error) {
|
func (m *MockPlaylistRepo) Exists(id string) (bool, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return false, errors.New("error")
|
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) {
|
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return 0, errors.New("error")
|
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) {
|
func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
if m.Err {
|
if m.Err {
|
||||||
return 0, errors.New("error")
|
return 0, errors.New("error")
|
||||||
}
|
}
|
||||||
return int64(len(m.Data)), nil
|
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)
|
var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil)
|
||||||
|
|||||||
@ -11,5 +11,7 @@ export const isReadOnly = (ownerId) => {
|
|||||||
|
|
||||||
export const isSmartPlaylist = (pls) => !!pls.rules
|
export const isSmartPlaylist = (pls) => !!pls.rules
|
||||||
|
|
||||||
|
export const isPluginPlaylist = (pls) => !!pls.pluginId
|
||||||
|
|
||||||
export const canChangeTracks = (pls) =>
|
export const canChangeTracks = (pls) =>
|
||||||
isWritable(pls.ownerId) && !isSmartPlaylist(pls)
|
isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !isPluginPlaylist(pls)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
isWritable,
|
isWritable,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isSmartPlaylist,
|
isSmartPlaylist,
|
||||||
|
isPluginPlaylist,
|
||||||
canChangeTracks,
|
canChangeTracks,
|
||||||
} from './playlistUtils'
|
} 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', () => {
|
describe('canChangeTracks', () => {
|
||||||
it('returns true if user is the owner and playlist is not smart', () => {
|
it('returns true if user is the owner and playlist is not smart', () => {
|
||||||
localStorage.setItem('userId', 'user1')
|
localStorage.setItem('userId', 'user1')
|
||||||
@ -74,5 +87,11 @@ describe('playlistUtils', () => {
|
|||||||
const playlist = { ownerId: 'user1', rules: [] }
|
const playlist = { ownerId: 'user1', rules: [] }
|
||||||
expect(canChangeTracks(playlist)).toBe(false)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -46,7 +46,7 @@ const createTestUtils = (mockDataProvider) =>
|
|||||||
data: mockIndexedData,
|
data: mockIndexedData,
|
||||||
list: {
|
list: {
|
||||||
cachedRequests: {
|
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'],
|
ids: ['sample-id1', 'sample-id2'],
|
||||||
total: 2,
|
total: 2,
|
||||||
|
|||||||
@ -264,7 +264,7 @@ export const SelectPlaylistInput = ({ onChange }) => {
|
|||||||
'playlist',
|
'playlist',
|
||||||
{ page: 1, perPage: -1 },
|
{ page: 1, perPage: -1 },
|
||||||
{ field: 'name', order: 'ASC' },
|
{ field: 'name', order: 'ASC' },
|
||||||
{ smart: false },
|
{ readonly: false },
|
||||||
)
|
)
|
||||||
|
|
||||||
const options =
|
const options =
|
||||||
|
|||||||
@ -53,7 +53,7 @@ const createTestComponent = (
|
|||||||
data: indexedData,
|
data: indexedData,
|
||||||
list: {
|
list: {
|
||||||
cachedRequests: {
|
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),
|
ids: Object.keys(indexedData),
|
||||||
total: Object.keys(indexedData).length,
|
total: Object.keys(indexedData).length,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
ReferenceInput,
|
ReferenceInput,
|
||||||
SelectInput,
|
SelectInput,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { isWritable, Title } from '../common'
|
import { isWritable, isPluginPlaylist, Title } from '../common'
|
||||||
|
|
||||||
const SyncFragment = ({ formData, variant, ...rest }) => {
|
const SyncFragment = ({ formData, variant, ...rest }) => {
|
||||||
return (
|
return (
|
||||||
@ -33,12 +33,17 @@ const PlaylistEditForm = (props) => {
|
|||||||
const { permissions } = usePermissions()
|
const { permissions } = usePermissions()
|
||||||
return (
|
return (
|
||||||
<SimpleForm redirect="list" variant={'outlined'} {...props}>
|
<SimpleForm redirect="list" variant={'outlined'} {...props}>
|
||||||
<TextInput source="name" validate={required()} />
|
<TextInput
|
||||||
|
source="name"
|
||||||
|
validate={required()}
|
||||||
|
disabled={isPluginPlaylist(record)}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
multiline
|
multiline
|
||||||
minRows={3}
|
minRows={3}
|
||||||
source="comment"
|
source="comment"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={isPluginPlaylist(record)}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
style: { resize: 'vertical' },
|
style: { resize: 'vertical' },
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
List,
|
List,
|
||||||
Writable,
|
Writable,
|
||||||
isWritable,
|
isWritable,
|
||||||
|
isPluginPlaylist,
|
||||||
useSelectedFields,
|
useSelectedFields,
|
||||||
useResourceRefresh,
|
useResourceRefresh,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
@ -115,7 +116,7 @@ const ToggleAutoImport = ({ resource, source }) => {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={record[source]}
|
checked={record[source]}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={!isWritable(record.ownerId)}
|
disabled={!isWritable(record.ownerId) || isPluginPlaylist(record)}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user