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()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
@ -92,9 +92,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
@ -121,9 +121,9 @@ func CreatePublicRouter() *public.Router {
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
@ -169,9 +169,9 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
@ -188,9 +188,9 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
@ -213,7 +213,8 @@ func getPluginManager() *plugins.Manager {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics, matcherMatcher)
|
||||
return manager
|
||||
}
|
||||
|
||||
|
||||
@ -324,7 +324,7 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
|
||||
// collectPlugins collects information about installed plugins
|
||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||
// TODO Fix import/inject cycles
|
||||
manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
|
||||
manager := plugins.GetManager(c.ds, events.GetBroker(), nil, nil)
|
||||
info := manager.GetPluginInfo()
|
||||
|
||||
result := make(map[string]insights.PluginInfo, len(info))
|
||||
|
||||
@ -113,7 +113,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
@ -163,6 +163,12 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Plugin playlists allow public toggle and cover art, but block name/comment changes
|
||||
if pls.IsPluginPlaylist() {
|
||||
if (name != nil && *name != pls.Name) || (comment != nil && *comment != pls.Comment) {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
}
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
repo := tx.Playlist(ctx)
|
||||
|
||||
@ -213,13 +219,13 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
|
||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart/plugin playlist).
|
||||
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
|
||||
@ -80,6 +80,8 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
})
|
||||
@ -125,6 +127,12 @@ var _ = Describe("Playlists", func() {
|
||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies replacing tracks on a plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-plugin", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
@ -137,6 +145,8 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
@ -182,12 +192,45 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies adding tracks to a plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-plugin", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies removing tracks from a plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-plugin", nil, nil, nil, nil, []int{0})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Smart"
|
||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies name update on a plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "New Name"
|
||||
err := ps.Update(ctx, "pls-plugin", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies comment update on a plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newComment := "New Comment"
|
||||
err := ps.Update(ctx, "pls-plugin", nil, &newComment, nil, nil, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("allows public toggle on a plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
public := true
|
||||
err := ps.Update(ctx, "pls-plugin", nil, nil, &public, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddTracks", func() {
|
||||
@ -199,6 +242,8 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
@ -232,6 +277,12 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies editing plugin playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-plugin", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
||||
@ -248,6 +299,8 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
@ -266,6 +319,12 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies on plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-plugin", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
||||
@ -282,6 +341,8 @@ var _ = Describe("Playlists", func() {
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-plugin": {ID: "pls-plugin", Name: "Plugin Playlist", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||
@ -299,6 +360,12 @@ var _ = Describe("Playlists", func() {
|
||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies on plugin playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-plugin", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetImage", func() {
|
||||
|
||||
@ -3,6 +3,7 @@ package playlists
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -68,6 +69,9 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
|
||||
pls.UploadedImage = "" // Managed by image upload endpoint
|
||||
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
|
||||
pls.EvaluatedAt = nil // Server-managed
|
||||
pls.PluginID = "" // Server-managed (plugin system)
|
||||
pls.PluginPlaylistID = "" // Server-managed (plugin system)
|
||||
pls.ValidUntil = nil // Server-managed (plugin system)
|
||||
err := s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -93,6 +97,19 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Plugin playlists allow public and ownership changes, but block name/comment
|
||||
if current.IsPluginPlaylist() && slices.ContainsFunc(cols, func(c string) bool {
|
||||
switch c {
|
||||
case "name":
|
||||
return entity.Name != current.Name
|
||||
case "comment":
|
||||
return entity.Comment != current.Comment
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Apply ownership change (admin only)
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
|
||||
@ -74,6 +74,9 @@ var _ = Describe("REST Adapter", func() {
|
||||
UploadedImage: "injected-image-path",
|
||||
ExternalImageURL: "http://evil.example.com/ssrf",
|
||||
EvaluatedAt: &now,
|
||||
PluginID: "fake-plugin",
|
||||
PluginPlaylistID: "fake-playlist-id",
|
||||
ValidUntil: &now,
|
||||
}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -90,6 +93,9 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(saved.UploadedImage).To(BeEmpty())
|
||||
Expect(saved.ExternalImageURL).To(BeEmpty())
|
||||
Expect(saved.EvaluatedAt).To(BeNil())
|
||||
Expect(saved.PluginID).To(BeEmpty())
|
||||
Expect(saved.PluginPlaylistID).To(BeEmpty())
|
||||
Expect(saved.ValidUntil).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
@ -102,6 +108,42 @@ var _ = Describe("REST Adapter", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies name change on plugin playlist", func() {
|
||||
mockPlsRepo.Data["pls-plugin"] = &model.Playlist{
|
||||
ID: "pls-plugin", Name: "Plugin PL", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix",
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Changed Name", Comment: ""}
|
||||
err := repo.Update("pls-plugin", pls, "name", "comment")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("denies comment change on plugin playlist", func() {
|
||||
mockPlsRepo.Data["pls-plugin"] = &model.Playlist{
|
||||
ID: "pls-plugin", Name: "Plugin PL", Comment: "", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix",
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Plugin PL", Comment: "new comment"}
|
||||
err := repo.Update("pls-plugin", pls, "name", "comment")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("allows public toggle on plugin playlist", func() {
|
||||
mockPlsRepo.Data["pls-plugin"] = &model.Playlist{
|
||||
ID: "pls-plugin", Name: "Plugin PL", OwnerID: "user-1",
|
||||
PluginID: "test-plugin", PluginPlaylistID: "daily-mix",
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Plugin PL", Public: true}
|
||||
err := repo.Update("pls-plugin", pls, "public")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
|
||||
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
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
|
||||
|
||||
// Plugin playlist attributes
|
||||
PluginID string `structs:"plugin_id" json:"pluginId,omitempty"`
|
||||
PluginPlaylistID string `structs:"plugin_playlist_id" json:"pluginPlaylistId,omitempty"`
|
||||
ValidUntil *time.Time `structs:"valid_until" json:"validUntil,omitempty"`
|
||||
}
|
||||
|
||||
func (pls Playlist) IsReadOnly() bool {
|
||||
return pls.IsSmartPlaylist() || pls.IsPluginPlaylist()
|
||||
}
|
||||
|
||||
func (pls Playlist) IsSmartPlaylist() bool {
|
||||
return pls.Rules != nil && pls.Rules.Expression != nil
|
||||
}
|
||||
|
||||
func (pls Playlist) IsPluginPlaylist() bool {
|
||||
return pls.PluginID != ""
|
||||
}
|
||||
|
||||
func (pls Playlist) MediaFiles() MediaFiles {
|
||||
if len(pls.Tracks) == 0 {
|
||||
return nil
|
||||
|
||||
@ -50,8 +50,9 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
"readonly": readonlyPlaylistFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
@ -73,6 +74,13 @@ func smartPlaylistFilter(string, any) Sqlizer {
|
||||
}
|
||||
}
|
||||
|
||||
func readonlyPlaylistFilter(string, any) Sqlizer {
|
||||
return And{
|
||||
smartPlaylistFilter("", nil),
|
||||
Or{Eq{"plugin_id": ""}, Eq{"plugin_id": nil}},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playlistRepository) userFilter() Sqlizer {
|
||||
user := loggedUser(r.ctx)
|
||||
if user.IsAdmin {
|
||||
|
||||
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/core/agents"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -66,16 +67,18 @@ type Manager struct {
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
metrics PluginMetricsRecorder
|
||||
matcher *matcher.Matcher
|
||||
}
|
||||
|
||||
// GetManager returns a singleton instance of the plugin manager.
|
||||
// The manager is not started automatically; call Start() to begin loading plugins.
|
||||
func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder) *Manager {
|
||||
func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder, mt *matcher.Matcher) *Manager {
|
||||
return singleton.GetInstance(func() *Manager {
|
||||
return &Manager{
|
||||
ds: ds,
|
||||
broker: broker,
|
||||
metrics: m,
|
||||
matcher: mt,
|
||||
plugins: make(map[string]*plugin),
|
||||
}
|
||||
})
|
||||
|
||||
@ -373,8 +373,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
return fmt.Errorf("manifest validation: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = &plugin{
|
||||
loadedPlugin := &plugin{
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
@ -386,10 +385,19 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
allUsers: p.AllUsers,
|
||||
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = loadedPlugin
|
||||
m.mu.Unlock()
|
||||
|
||||
// Call plugin init function
|
||||
callPluginInit(ctx, m.plugins[p.ID])
|
||||
callPluginInit(ctx, loadedPlugin)
|
||||
|
||||
// Start PlaylistProvider syncer if capability is detected
|
||||
if hasCapability(loadedPlugin.capabilities, CapabilityPlaylistProvider) {
|
||||
syncer := newPlaylistSyncer(m.ctx, p.ID, loadedPlugin, m.ds, m.matcher)
|
||||
loadedPlugin.closers = append(loadedPlugin.closers, syncer)
|
||||
go syncer.run()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -65,6 +65,13 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||
}
|
||||
}
|
||||
|
||||
// PlaylistProvider capability requires users permission
|
||||
if hasCapability(capabilities, CapabilityPlaylistProvider) {
|
||||
if m.Permissions == nil || m.Permissions.Users == nil {
|
||||
return fmt.Errorf("playlist provider capability requires 'users' permission to be declared in manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler permission requires SchedulerCallback capability
|
||||
if m.Permissions != nil && m.Permissions.Scheduler != nil {
|
||||
if !hasCapability(capabilities, CapabilityScheduler) {
|
||||
|
||||
@ -463,5 +463,48 @@ var _ = Describe("Manifest", func() {
|
||||
err := ValidateWithCapabilities(m, []Capability{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("validates playlist provider capability with users permission", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test",
|
||||
Author: "Author",
|
||||
Version: "1.0.0",
|
||||
Permissions: &Permissions{
|
||||
Users: &UsersPermission{},
|
||||
},
|
||||
}
|
||||
|
||||
err := ValidateWithCapabilities(m, []Capability{CapabilityPlaylistProvider})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when playlist provider capability without users permission", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test",
|
||||
Author: "Author",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
err := ValidateWithCapabilities(m, []Capability{CapabilityPlaylistProvider})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("playlist provider"))
|
||||
Expect(err.Error()).To(ContainSubstring("users"))
|
||||
})
|
||||
|
||||
It("returns error when playlist provider has permissions but no users", func() {
|
||||
m := &Manifest{
|
||||
Name: "Test",
|
||||
Author: "Author",
|
||||
Version: "1.0.0",
|
||||
Permissions: &Permissions{
|
||||
Http: &HTTPPermission{},
|
||||
},
|
||||
}
|
||||
|
||||
err := ValidateWithCapabilities(m, []Capability{CapabilityPlaylistProvider})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("playlist provider"))
|
||||
Expect(err.Error()).To(ContainSubstring("users"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 lyrics;
|
||||
pub mod metadata;
|
||||
pub mod playlistprovider;
|
||||
pub mod scheduler;
|
||||
pub mod scrobbler;
|
||||
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/configtest"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@ -81,10 +82,26 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu
|
||||
return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...)
|
||||
}
|
||||
|
||||
// pluginOverride allows tests to override model.Plugin fields for specific plugins.
|
||||
type pluginOverride struct {
|
||||
AllUsers bool
|
||||
Users string // JSON array of user IDs, e.g. `["user-1"]`
|
||||
}
|
||||
|
||||
// createTestManagerWithPluginOverrides creates a new plugin Manager with the given plugin config,
|
||||
// per-plugin overrides, and specified plugins.
|
||||
func createTestManagerWithPluginOverrides(pluginConfig map[string]map[string]string, overrides map[string]pluginOverride, plugins ...string) (*Manager, string) {
|
||||
return createTestManagerFull(pluginConfig, overrides, noopMetricsRecorder{}, plugins...)
|
||||
}
|
||||
|
||||
// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config,
|
||||
// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
|
||||
// Returns the manager and temp directory path.
|
||||
func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
|
||||
return createTestManagerFull(pluginConfig, nil, metrics, plugins...)
|
||||
}
|
||||
|
||||
func createTestManagerFull(pluginConfig map[string]map[string]string, overrides map[string]pluginOverride, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -113,14 +130,21 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
|
||||
configJSON = string(configBytes)
|
||||
}
|
||||
|
||||
enabledPlugins = append(enabledPlugins, model.Plugin{
|
||||
p := model.Plugin{
|
||||
ID: pluginName,
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
Config: configJSON,
|
||||
AllUsers: true, // Allow all users by default in tests
|
||||
})
|
||||
}
|
||||
if overrides != nil {
|
||||
if o, ok := overrides[pluginName]; ok {
|
||||
p.AllUsers = o.AllUsers
|
||||
p.Users = o.Users
|
||||
}
|
||||
}
|
||||
enabledPlugins = append(enabledPlugins, p)
|
||||
}
|
||||
|
||||
// Setup config
|
||||
@ -133,12 +157,19 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(enabledPlugins)
|
||||
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
||||
|
||||
// Pre-seed a mock user repo with a default user so that
|
||||
// PlaylistProvider's discoverAndSync can resolve usernames.
|
||||
mockUserRepo := tests.CreateMockUserRepo()
|
||||
_ = mockUserRepo.Put(&model.User{ID: "user-1", UserName: "admin"})
|
||||
|
||||
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedUser: mockUserRepo, MockedPlaylist: tests.CreateMockPlaylistRepo()}
|
||||
|
||||
// Create and start manager
|
||||
manager := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
matcher: matcher.New(dataStore),
|
||||
metrics: metrics,
|
||||
subsonicRouter: http.NotFoundHandler(), // Stub router for tests
|
||||
}
|
||||
|
||||
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{}
|
||||
|
||||
if p.IsSmartPlaylist() {
|
||||
if p.IsReadOnly() {
|
||||
pls.Readonly = true
|
||||
|
||||
if p.EvaluatedAt != nil {
|
||||
if p.IsSmartPlaylist() && p.EvaluatedAt != nil {
|
||||
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
|
||||
} else if p.IsPluginPlaylist() && p.ValidUntil != nil {
|
||||
pls.ValidUntil = p.ValidUntil
|
||||
}
|
||||
} else {
|
||||
user, ok := request.UserFrom(ctx)
|
||||
|
||||
@ -156,6 +156,51 @@ var _ = Describe("buildPlaylist", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("plugin playlist", func() {
|
||||
BeforeEach(func() {
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
|
||||
playlist = model.Playlist{
|
||||
ID: "pls-plugin",
|
||||
Name: "Daily Mix",
|
||||
Comment: "Generated by plugin",
|
||||
OwnerName: "admin",
|
||||
OwnerID: "1234",
|
||||
Public: false,
|
||||
SongCount: 5,
|
||||
Duration: 300,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
PluginID: "test-plugin",
|
||||
PluginPlaylistID: "daily-mix",
|
||||
}
|
||||
})
|
||||
|
||||
It("marks plugin playlist as readonly with no ValidUntil when not set", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"})
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
Expect(result.Readonly).To(BeTrue())
|
||||
Expect(result.ValidUntil).To(BeNil())
|
||||
})
|
||||
|
||||
It("exposes ValidUntil when set on the model", func() {
|
||||
validUntil := time.Date(2023, 3, 1, 12, 0, 0, 0, time.UTC)
|
||||
playlist.ValidUntil = &validUntil
|
||||
|
||||
ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"})
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
Expect(result.Readonly).To(BeTrue())
|
||||
Expect(result.ValidUntil).To(Equal(&validUntil))
|
||||
})
|
||||
|
||||
It("marks plugin playlist as readonly even for non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", UserName: "other"})
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
Expect(result.Readonly).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("smart playlist", func() {
|
||||
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
|
||||
validUntil := evaluatedAt.Add(5 * time.Second)
|
||||
|
||||
@ -2,6 +2,7 @@ package tests
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -17,6 +18,7 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo {
|
||||
|
||||
type MockPlaylistRepo struct {
|
||||
model.PlaylistRepository
|
||||
mu sync.RWMutex
|
||||
Data map[string]*model.Playlist // keyed by ID
|
||||
PathMap map[string]*model.Playlist // keyed by path
|
||||
Last *model.Playlist
|
||||
@ -26,10 +28,14 @@ type MockPlaylistRepo struct {
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) SetError(err bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Err = err
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
@ -46,6 +52,8 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist,
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
@ -60,6 +68,8 @@ func (m *MockPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
@ -72,6 +82,8 @@ func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Delete(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
@ -80,10 +92,14 @@ func (m *MockPlaylistRepo) Delete(id string) error {
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.TracksRepo
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Exists(id string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.Err {
|
||||
return false, errors.New("error")
|
||||
}
|
||||
@ -95,6 +111,8 @@ func (m *MockPlaylistRepo) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
@ -102,10 +120,39 @@ func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
// Len returns the number of playlists in the repo in a thread-safe manner.
|
||||
func (m *MockPlaylistRepo) Len() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.Data)
|
||||
}
|
||||
|
||||
// GetData returns a playlist by ID in a thread-safe manner.
|
||||
func (m *MockPlaylistRepo) GetData(id string) (*model.Playlist, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
pls, ok := m.Data[id]
|
||||
return pls, ok
|
||||
}
|
||||
|
||||
// FindByPluginPlaylistID returns the first playlist with the given plugin playlist ID.
|
||||
func (m *MockPlaylistRepo) FindByPluginPlaylistID(pluginPlaylistID string) *model.Playlist {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, pls := range m.Data {
|
||||
if pls.PluginPlaylistID == pluginPlaylistID {
|
||||
return pls
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil)
|
||||
|
||||
@ -11,5 +11,7 @@ export const isReadOnly = (ownerId) => {
|
||||
|
||||
export const isSmartPlaylist = (pls) => !!pls.rules
|
||||
|
||||
export const isPluginPlaylist = (pls) => !!pls.pluginId
|
||||
|
||||
export const canChangeTracks = (pls) =>
|
||||
isWritable(pls.ownerId) && !isSmartPlaylist(pls)
|
||||
isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !isPluginPlaylist(pls)
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
isWritable,
|
||||
isReadOnly,
|
||||
isSmartPlaylist,
|
||||
isPluginPlaylist,
|
||||
canChangeTracks,
|
||||
} from './playlistUtils'
|
||||
|
||||
@ -56,6 +57,18 @@ describe('playlistUtils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPluginPlaylist', () => {
|
||||
it('returns true if playlist has pluginId', () => {
|
||||
const playlist = { pluginId: 'test-plugin' }
|
||||
expect(isPluginPlaylist(playlist)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false if playlist does not have pluginId', () => {
|
||||
const playlist = {}
|
||||
expect(isPluginPlaylist(playlist)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canChangeTracks', () => {
|
||||
it('returns true if user is the owner and playlist is not smart', () => {
|
||||
localStorage.setItem('userId', 'user1')
|
||||
@ -74,5 +87,11 @@ describe('playlistUtils', () => {
|
||||
const playlist = { ownerId: 'user1', rules: [] }
|
||||
expect(canChangeTracks(playlist)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false if playlist is a plugin playlist', () => {
|
||||
localStorage.setItem('userId', 'user1')
|
||||
const playlist = { ownerId: 'user1', pluginId: 'test-plugin' }
|
||||
expect(canChangeTracks(playlist)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -46,7 +46,7 @@ const createTestUtils = (mockDataProvider) =>
|
||||
data: mockIndexedData,
|
||||
list: {
|
||||
cachedRequests: {
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"readonly":false}}':
|
||||
{
|
||||
ids: ['sample-id1', 'sample-id2'],
|
||||
total: 2,
|
||||
|
||||
@ -264,7 +264,7 @@ export const SelectPlaylistInput = ({ onChange }) => {
|
||||
'playlist',
|
||||
{ page: 1, perPage: -1 },
|
||||
{ field: 'name', order: 'ASC' },
|
||||
{ smart: false },
|
||||
{ readonly: false },
|
||||
)
|
||||
|
||||
const options =
|
||||
|
||||
@ -53,7 +53,7 @@ const createTestComponent = (
|
||||
data: indexedData,
|
||||
list: {
|
||||
cachedRequests: {
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}':
|
||||
'{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"readonly":false}}':
|
||||
{
|
||||
ids: Object.keys(indexedData),
|
||||
total: Object.keys(indexedData).length,
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
} from 'react-admin'
|
||||
import { isWritable, Title } from '../common'
|
||||
import { isWritable, isPluginPlaylist, Title } from '../common'
|
||||
|
||||
const SyncFragment = ({ formData, variant, ...rest }) => {
|
||||
return (
|
||||
@ -33,12 +33,17 @@ const PlaylistEditForm = (props) => {
|
||||
const { permissions } = usePermissions()
|
||||
return (
|
||||
<SimpleForm redirect="list" variant={'outlined'} {...props}>
|
||||
<TextInput source="name" validate={required()} />
|
||||
<TextInput
|
||||
source="name"
|
||||
validate={required()}
|
||||
disabled={isPluginPlaylist(record)}
|
||||
/>
|
||||
<TextInput
|
||||
multiline
|
||||
minRows={3}
|
||||
source="comment"
|
||||
fullWidth
|
||||
disabled={isPluginPlaylist(record)}
|
||||
inputProps={{
|
||||
style: { resize: 'vertical' },
|
||||
}}
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
List,
|
||||
Writable,
|
||||
isWritable,
|
||||
isPluginPlaylist,
|
||||
useSelectedFields,
|
||||
useResourceRefresh,
|
||||
} from '../common'
|
||||
@ -115,7 +116,7 @@ const ToggleAutoImport = ({ resource, source }) => {
|
||||
<Switch
|
||||
checked={record[source]}
|
||||
onClick={handleClick}
|
||||
disabled={!isWritable(record.ownerId)}
|
||||
disabled={!isWritable(record.ownerId) || isPluginPlaylist(record)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user