feat(plugins): add path to Scrobbler and Lyrics plugin TrackInfo (#5339)
* feat: add Path to TrackInfo struct * refactor: improve naming to follow the rest of the code * test: add tests * fix: actually check for filesystem permission * refactor: remove library logic from specific plugins * refactor: move hasFilesystemPermission to a Manifest method * test(plugins): add unit tests for hasLibraryFilesystemAccess method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): remove hasFilesystemPerm field and use manifest for filesystem permission checks Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): streamline library filesystem access checks in lyrics and scrobbler adapters Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
501c6eaf8f
commit
85e9982b43
16 changed files with 150 additions and 7 deletions
|
|
@ -102,6 +102,11 @@ components:
|
||||||
mbzReleaseTrackId:
|
mbzReleaseTrackId:
|
||||||
type: string
|
type: string
|
||||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
Path is the full path to the track file, relative to the library root.
|
||||||
|
Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- title
|
- title
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ type TrackInfo struct {
|
||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
// Path is the full path to the track file, relative to the library root.
|
||||||
|
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NowPlayingRequest is the request for now playing notification.
|
// NowPlayingRequest is the request for now playing notification.
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,11 @@ components:
|
||||||
mbzReleaseTrackId:
|
mbzReleaseTrackId:
|
||||||
type: string
|
type: string
|
||||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: |-
|
||||||
|
Path is the full path to the track file, relative to the library root.
|
||||||
|
Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- title
|
- title
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ type LyricsPlugin struct {
|
||||||
// using model.ToLyrics.
|
// using model.ToLyrics.
|
||||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||||
req := capabilities.GetLyricsRequest{
|
req := capabilities.GetLyricsRequest{
|
||||||
Track: mediaFileToTrackInfo(mf),
|
Track: mediaFileToTrackInfo(l.plugin, mf),
|
||||||
}
|
}
|
||||||
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
||||||
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure filesystem access for library permission
|
// Configure filesystem access for library permission
|
||||||
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
|
if pkg.Manifest.HasLibraryFilesystemPermission() {
|
||||||
adminCtx := adminContext(ctx)
|
adminCtx := adminContext(ctx)
|
||||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -384,6 +384,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||||
metrics: m.metrics,
|
metrics: m.metrics,
|
||||||
allowedUserIDs: allowedUsers,
|
allowedUserIDs: allowedUsers,
|
||||||
allUsers: p.AllUsers,
|
allUsers: p.AllUsers,
|
||||||
|
libraries: newLibraryAccess(allowedLibraries, p.AllLibraries),
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type plugin struct {
|
||||||
metrics PluginMetricsRecorder
|
metrics PluginMetricsRecorder
|
||||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||||
allUsers bool // If true, plugin can access all users
|
allUsers bool // If true, plugin can access all users
|
||||||
|
libraries libraryAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
// instance creates a new plugin instance for the given context.
|
// instance creates a new plugin instance for the given context.
|
||||||
|
|
@ -47,3 +48,30 @@ func (p *plugin) Close() error {
|
||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *plugin) hasLibraryFilesystemAccess(libID int) bool {
|
||||||
|
return p.manifest.HasLibraryFilesystemPermission() && p.libraries.contains(libID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryAccess captures the set of libraries a plugin is permitted to see,
|
||||||
|
// precomputed at load time for O(1) lookup.
|
||||||
|
type libraryAccess struct {
|
||||||
|
allLibraries bool
|
||||||
|
libraryIDSet map[int]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLibraryAccess(allowedLibraryIDs []int, allLibraries bool) libraryAccess {
|
||||||
|
set := make(map[int]struct{}, len(allowedLibraryIDs))
|
||||||
|
for _, id := range allowedLibraryIDs {
|
||||||
|
set[id] = struct{}{}
|
||||||
|
}
|
||||||
|
return libraryAccess{allLibraries: allLibraries, libraryIDSet: set}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a libraryAccess) contains(libID int) bool {
|
||||||
|
if a.allLibraries {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := a.libraryIDSet[libID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
|
||||||
34
plugins/manager_plugin_test.go
Normal file
34
plugins/manager_plugin_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("plugin", func() {
|
||||||
|
Describe("hasLibraryFilesystemAccess", func() {
|
||||||
|
fsManifest := &Manifest{
|
||||||
|
Permissions: &Permissions{
|
||||||
|
Library: &LibraryPermission{Filesystem: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
It("returns false when the manifest does not grant filesystem permission", func() {
|
||||||
|
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess(nil, true)}
|
||||||
|
Expect(p.hasLibraryFilesystemAccess(1)).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns true for any library when allLibraries is set", func() {
|
||||||
|
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess(nil, true)}
|
||||||
|
Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue())
|
||||||
|
Expect(p.hasLibraryFilesystemAccess(42)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns true only for libraries in the allowed list", func() {
|
||||||
|
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1, 3}, false)}
|
||||||
|
Expect(p.hasLibraryFilesystemAccess(1)).To(BeTrue())
|
||||||
|
Expect(p.hasLibraryFilesystemAccess(3)).To(BeTrue())
|
||||||
|
Expect(p.hasLibraryFilesystemAccess(2)).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -86,3 +86,10 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||||
func (m *Manifest) HasExperimentalThreads() bool {
|
func (m *Manifest) HasExperimentalThreads() bool {
|
||||||
return m.Experimental != nil && m.Experimental.Threads != nil
|
return m.Experimental != nil && m.Experimental.Threads != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries.
|
||||||
|
func (m *Manifest) HasLibraryFilesystemPermission() bool {
|
||||||
|
return m.Permissions != nil &&
|
||||||
|
m.Permissions.Library != nil &&
|
||||||
|
m.Permissions.Library.Filesystem
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ type TrackInfo struct {
|
||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
// Path is the full path to the track file, relative to the library root.
|
||||||
|
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lyrics requires all methods to be implemented.
|
// Lyrics requires all methods to be implemented.
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ type TrackInfo struct {
|
||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
// Path is the full path to the track file, relative to the library root.
|
||||||
|
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lyrics requires all methods to be implemented.
|
// Lyrics requires all methods to be implemented.
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ type TrackInfo struct {
|
||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
// Path is the full path to the track file, relative to the library root.
|
||||||
|
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrobbler requires all methods to be implemented.
|
// Scrobbler requires all methods to be implemented.
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,9 @@ type TrackInfo struct {
|
||||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||||
|
// Path is the full path to the track file, relative to the library root.
|
||||||
|
// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrobbler requires all methods to be implemented.
|
// Scrobbler requires all methods to be implemented.
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ pub struct TrackInfo {
|
||||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub mbz_release_track_id: String,
|
pub mbz_release_track_id: String,
|
||||||
|
/// Path is the full path to the track file, relative to the library root.
|
||||||
|
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error represents an error from a capability method.
|
/// Error represents an error from a capability method.
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@ pub struct TrackInfo {
|
||||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub mbz_release_track_id: String,
|
pub mbz_release_track_id: String,
|
||||||
|
/// Path is the full path to the track file, relative to the library root.
|
||||||
|
/// Only included if the plugin has library permission with filesystem access for the track's library.
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error represents an error from a capability method.
|
/// Error represents an error from a capability method.
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *
|
||||||
username := getUsernameFromContext(ctx)
|
username := getUsernameFromContext(ctx)
|
||||||
input := capabilities.NowPlayingRequest{
|
input := capabilities.NowPlayingRequest{
|
||||||
Username: username,
|
Username: username,
|
||||||
Track: mediaFileToTrackInfo(track),
|
Track: mediaFileToTrackInfo(s.plugin, track),
|
||||||
Position: int32(position),
|
Position: int32(position),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobb
|
||||||
username := getUsernameFromContext(ctx)
|
username := getUsernameFromContext(ctx)
|
||||||
input := capabilities.ScrobbleRequest{
|
input := capabilities.ScrobbleRequest{
|
||||||
Username: username,
|
Username: username,
|
||||||
Track: mediaFileToTrackInfo(&sc.MediaFile),
|
Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile),
|
||||||
Timestamp: sc.TimeStamp.Unix(),
|
Timestamp: sc.TimeStamp.Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,9 +109,11 @@ func getUsernameFromContext(ctx context.Context) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo
|
// mediaFileToTrackInfo converts a model.MediaFile to capabilities.TrackInfo.
|
||||||
func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
|
// Path is populated only when the plugin is allowed filesystem access to the
|
||||||
return capabilities.TrackInfo{
|
// track's library.
|
||||||
|
func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo {
|
||||||
|
ti := capabilities.TrackInfo{
|
||||||
ID: mf.ID,
|
ID: mf.ID,
|
||||||
Title: mf.Title,
|
Title: mf.Title,
|
||||||
Album: mf.Album,
|
Album: mf.Album,
|
||||||
|
|
@ -127,6 +129,10 @@ func mediaFileToTrackInfo(mf *model.MediaFile) capabilities.TrackInfo {
|
||||||
MBZReleaseGroupID: mf.MbzReleaseGroupID,
|
MBZReleaseGroupID: mf.MbzReleaseGroupID,
|
||||||
MBZReleaseTrackID: mf.MbzReleaseTrackID,
|
MBZReleaseTrackID: mf.MbzReleaseTrackID,
|
||||||
}
|
}
|
||||||
|
if p.hasLibraryFilesystemAccess(mf.LibraryID) {
|
||||||
|
ti.Path = mf.Path
|
||||||
|
}
|
||||||
|
return ti
|
||||||
}
|
}
|
||||||
|
|
||||||
// participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef
|
// participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,40 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||||
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("mediaFileToTrackInfo", func() {
|
||||||
|
var track *model.MediaFile
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
track = &model.MediaFile{
|
||||||
|
ID: "track-1",
|
||||||
|
Title: "Test Song",
|
||||||
|
Path: "/music/test.flac",
|
||||||
|
LibraryID: 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fsManifest := &Manifest{
|
||||||
|
Permissions: &Permissions{
|
||||||
|
Library: &LibraryPermission{Filesystem: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
It("includes Path when the plugin has filesystem access to the track's library", func() {
|
||||||
|
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)}
|
||||||
|
Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits Path when the plugin lacks filesystem permission", func() {
|
||||||
|
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)}
|
||||||
|
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("omits Path when the track's library is not in the allowed set", func() {
|
||||||
|
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)}
|
||||||
|
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("mapScrobblerError", func() {
|
var _ = Describe("mapScrobblerError", func() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue