Compare commits

...

24 commits

Author SHA1 Message Date
Deluan
439cff47ac feat: add server-managed fields for plugin playlists in rest adapter
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-12 17:38:21 -04:00
Deluan
ae8263671a fix: address PR review comments for playlist provider capability
Fix timer lifecycle bugs in the playlist syncer: always store
RetryInterval (including 0 to disable retries), cancel discovery timers
when RefreshInterval becomes 0, and cancel stale refresh timers when
ValidUntil becomes 0. Extract cancelRefreshTimer helper to deduplicate
the timer cleanup pattern. Improve plugin playlist update restrictions
in both the Subsonic and REST API paths to compare actual values instead
of just checking pointer presence or field inclusion, so passing
unchanged name/comment no longer triggers a false rejection.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-12 17:38:21 -04:00
Deluan
9ddbcbf6b4 feat: persist and expose plugin playlist ValidUntil in Subsonic API
Add a ValidUntil field to the Playlist model and persist it from the
plugin's GetPlaylistResponse during sync. This allows clients to know
when a plugin playlist's data will be refreshed. The value is exposed
in the OpenSubsonic playlist response alongside the existing
smart playlist ValidUntil calculation. The migration is consolidated
into a single multi-statement ExecContext call.
2026-04-12 17:38:21 -04:00
Deluan
a5fd18dc67 feat: treat plugin playlists as read-only everywhere
Enforce read-only restrictions for plugin playlists (PluginID != "")
consistently across backend and UI, matching the existing smart playlist
behavior. The Subsonic buildOSPlaylist now uses IsReadOnly() to mark both
smart and plugin playlists as readonly. Backend Update and REST adapter
block name/comment changes on plugin playlists while still allowing
public toggle and cover art. The UI adds isPluginPlaylist helper to
canChangeTracks, disables name/comment inputs in PlaylistEdit, disables
auto-import toggle in PlaylistList, and adds a server-side readonly
filter to exclude both smart and plugin playlists from the "add to
playlist" dialog.
2026-04-12 17:38:21 -04:00
Deluan
0a67142f74 refactor: update playlistSyncer methods to use consistent receiver naming
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-12 17:38:21 -04:00
Deluan
73203eeef0 refactor: simplify and improve playlist provider and matcher code
Eliminate redundant work and minor issues found during code review:
- Replace manual PlaylistTrack construction in syncPlaylist with the
  existing Playlist.AddMediaFiles helper, removing duplicated logic
- Pre-sanitize track fields once per artist batch in the matcher's
  fuzzy matching loop, avoiding redundant sanitization in both
  findBestMatch and computeSpecificityLevel on every iteration
- Cache resolved usernames in discoverAndSync to avoid N+1 DB lookups
  when multiple playlists share the same owner
- Use the local loadedPlugin variable instead of reading m.plugins[p.ID]
  after releasing the lock in loadPluginWithConfig
- Fix misleading uint32 comparison (<=0 to ==0) in durationProximity
- Update stale comment on checkTracksEditable to mention plugin playlists
2026-04-12 17:38:21 -04:00
Deluan
9053a4ffe9 feat(plugins): require users permission for PlaylistProvider and validate owner
PlaylistProvider capability now requires 'users' permission in the
manifest (matching existing Scrobbler behavior) and validates that the
resolved owner user ID is in the plugin's allowed users list before
creating playlists.
2026-04-12 17:38:21 -04:00
Deluan
657119fcb3 refactor(plugins): increase work channel capacity for improved playlist processing
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-12 17:38:21 -04:00
Deluan
f9af67d45e refactor(plugins): streamline plugin loading and synchronization logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-12 17:38:21 -04:00
Deluan
04aa10f988 refactor(plugins): rename PlaylistGenerator to PlaylistProvider
Rename the capability from PlaylistGenerator to PlaylistProvider and the
internal orchestrator struct from playlistGeneratorOrchestrator to
playlistSyncer. The new names better describe what the capability does
(provides playlists) rather than how it works internally. All source
files, test plugin, PDK packages (Go/Rust), YAML schemas, and exported
WASM function names are updated accordingly.
2026-04-12 17:38:21 -04:00
Deluan
03a36c5bdd refactor: inject matcher.Matcher via Wire instead of creating it inline
Replaced inline matcher.New(ds) calls in external.provider and
PlaylistGenerator orchestrator with proper dependency injection via
Google Wire. Added matcher.New to the Wire provider set, updated
NewProvider and GetManager signatures to accept *matcher.Matcher, and
deleted the trivial provider_matching.go wrapper. This eliminates tight
coupling where each caller knew how to construct a Matcher, following
the same DI pattern used by other core services.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-12 17:38:21 -04:00
Deluan
0127b8c607 refactor(plugins): move PlaylistGenerator orchestrator into plugin closers
Move the PlaylistGenerator orchestrator from a separate map on the Manager
into each plugin's closers list, giving it the same lifecycle as other host
services (Scheduler, TaskQueue, WebSocket). The orchestrator now implements
io.Closer and is created during plugin load, so it starts/stops automatically
with the plugin. This removes the playlistGenerators map, the
startPlaylistGenerators() method, and special-case cleanup code from both
Stop() and unloadPlugin(), simplifying reload and unload paths.
2026-04-12 17:32:18 -04:00
Deluan
9c516cf601 refactor(plugins): serialize PlaylistGenerator plugin calls via work queue
Replace the timer-per-playlist + WaitGroup.Go model with a single worker
goroutine processing work items from a buffered channel. This eliminates
race conditions from parallel plugin calls (rate limiting risk),
unsynchronized map/field access across goroutines, and overlapping
discovery and refresh timers. The worker owns all mutable state
(refreshTimers, discoveryTimer) exclusively, while retryInterval and
refreshTimerCount use atomics for safe test observability. Also adds
retry-on-failure for GetAvailablePlaylists (5 min delay) and makes
MockPlaylistRepo thread-safe with sync.RWMutex.
2026-04-12 17:32:18 -04:00
Deluan
fde4517339 refactor(plugins): rename GetPlaylists, add NotFound error and RetryInterval
Rename GetPlaylists → GetAvailablePlaylists for clarity. Add
PlaylistGeneratorErrorNotFound so plugins can signal a playlist is
temporarily unavailable (orchestrator skips it but retries on next
discovery). Add RetryInterval on GetAvailablePlaylistsResponse for
automatic retry of transient GetPlaylist failures.
2026-04-12 17:32:18 -04:00
Deluan
a8a336de33 fix: address race conditions and design issues in PlaylistGenerator
Rename PlaylistInfo.OwnerUserID to OwnerUsername so plugins provide
usernames instead of internal user IDs, with server-side resolution via
FindByUsername. Fix race condition on playlistGenerators map by using
write lock in startPlaylistGenerators and moving map access inside the
lock in unloadPlugin/Stop. Add context and WaitGroup to the orchestrator
for proper cancellation and goroutine tracking. Include owner_id in the
plugin playlist unique index. Use SetTracks instead of direct assignment
to refresh playlist duration/size/song count stats.
2026-04-12 17:32:18 -04:00
Deluan
188584e3fb test(plugins): add PlaylistGenerator integration tests with test WASM plugin
Add a test playlist generator plugin and integration tests for the
PlaylistGenerator capability. The test plugin exercises GetPlaylists and
GetPlaylist WASM functions, and the tests verify orchestration flow
including capability detection, playlist discovery/sync, deterministic
ID generation, error handling, and graceful stop.

Also initialize playlistGenerators map in the test helper to prevent nil
map panics when loading plugins with PlaylistGenerator capability.
2026-04-12 17:32:18 -04:00
Deluan
dedaf8e64a feat: generate PDK code for PlaylistGenerator capability
Generated via ndpgen: Go PDK wrappers, Rust PDK wrappers,
and XTP schema for non-Go plugins.
2026-04-12 17:32:18 -04:00
Deluan
4908dae21f feat: integrate PlaylistGenerator orchestration into plugin manager lifecycle
Start playlist generator orchestrators after loading enabled plugins.
Stop them on manager shutdown and when individual plugins are unloaded.
2026-04-12 17:32:18 -04:00
Deluan
22ae5bac54 feat: implement PlaylistGenerator orchestration logic
Orchestrates plugin playlist lifecycle: discovery via GetPlaylists,
data fetch via GetPlaylist, track matching via core/matcher, DB upsert,
and timer-based refresh (ValidUntil per playlist, RefreshInterval for
re-discovery).
2026-04-12 17:32:18 -04:00
Deluan
002c7612bf feat: register PlaylistGenerator capability with WASM function names 2026-04-12 17:32:18 -04:00
Deluan
520a3e9c55 feat: define PlaylistGenerator plugin capability interface 2026-04-12 17:32:18 -04:00
Deluan
bd0af7fd38 feat: add read-only guards for plugin-managed playlists
Plugin playlists (PluginID != "") are now treated as read-only, like
smart playlists. Track mutations (add/remove/reorder) and track
replacement via Create are blocked with ErrNotAuthorized.
2026-04-12 17:32:18 -04:00
Deluan
4600ed7c28 feat: add PluginID and PluginPlaylistID fields to Playlist model 2026-04-12 17:32:18 -04:00
Deluan
4f47022f7f feat: add plugin_id and plugin_playlist_id columns to playlist table 2026-04-12 17:32:18 -04:00
36 changed files with 1720 additions and 33 deletions

View file

@ -71,9 +71,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)
@ -93,9 +93,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()
@ -122,9 +122,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()
@ -170,9 +170,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)
@ -189,9 +189,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)
@ -214,7 +214,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
}

View file

@ -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))

View file

@ -112,7 +112,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 {
@ -162,6 +162,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)
@ -212,13 +218,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

View file

@ -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() {

View file

@ -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

View file

@ -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)

View 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
}

View file

@ -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

View file

@ -52,8 +52,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",
@ -75,6 +76,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 {

View 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) }

View 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

View file

@ -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),
}
})

View file

@ -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
}

View file

@ -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) {

View file

@ -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"))
})
})
})

View 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
}

View 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) {}

View file

@ -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;

View 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))
}
};
}

View 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
}

View 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())
})
})
})

View file

@ -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
}

View 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

View 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=

View 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() {}

View 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"
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
})
})
})

View file

@ -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,

View file

@ -264,7 +264,7 @@ export const SelectPlaylistInput = ({ onChange }) => {
'playlist',
{ page: 1, perPage: -1 },
{ field: 'name', order: 'ASC' },
{ smart: false },
{ readonly: false },
)
const options =

View file

@ -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,

View file

@ -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' },
}}

View file

@ -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
}