Merge 439cff47ac3d5d32487a9f81b82a3f4a78756a76 into 5d1c1157b5bde16c2b0ff6017bfe4a20bdbb6e7c

This commit is contained in:
Deluan Quintão 2026-04-27 14:11:29 +00:00 committed by GitHub
commit 393fd09f7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1720 additions and 33 deletions

View File

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

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

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

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

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

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
}