mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-28 21:07:38 -05:00
Compare commits
4 Commits
update-tra
...
feat/lyric
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6920ce9f6e | ||
|
|
4a53650981 | ||
|
|
d9a215e1e3 | ||
|
|
d134de1061 |
@@ -1,6 +1,6 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -103,7 +104,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
|
||||
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -207,7 +209,7 @@ func getPluginManager() *plugins.Manager {
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
@@ -44,6 +45,7 @@ var allProviders = wire.NewSet(
|
||||
plugins.GetManager,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||
|
||||
@@ -9,7 +9,29 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
// Lyrics can fetch lyrics for a media file.
|
||||
type Lyrics interface {
|
||||
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
|
||||
}
|
||||
|
||||
// PluginLoader discovers and loads lyrics provider plugins.
|
||||
type PluginLoader interface {
|
||||
LoadLyricsProvider(name string) (Lyrics, bool)
|
||||
}
|
||||
|
||||
type lyricsService struct {
|
||||
pluginLoader PluginLoader
|
||||
}
|
||||
|
||||
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
|
||||
// system is available.
|
||||
func NewLyrics(pluginLoader PluginLoader) Lyrics {
|
||||
return &lyricsService{pluginLoader: pluginLoader}
|
||||
}
|
||||
|
||||
// GetLyrics returns lyrics for the given media file, trying sources in the
|
||||
// order specified by conf.Server.LyricsPriority.
|
||||
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
var lyricsList model.LyricList
|
||||
var err error
|
||||
|
||||
@@ -21,11 +43,11 @@ func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error
|
||||
case strings.HasPrefix(pattern, "."):
|
||||
lyricsList, err = fromExternalFile(ctx, mf, pattern)
|
||||
default:
|
||||
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
|
||||
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
|
||||
log.Error(ctx, "error getting lyrics", "source", pattern, err)
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package lyrics_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -72,7 +73,8 @@ var _ = Describe("sources", func() {
|
||||
|
||||
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
|
||||
conf.Server.LyricsPriority = priority
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(expected))
|
||||
},
|
||||
@@ -107,7 +109,8 @@ var _ = Describe("sources", func() {
|
||||
It("should fallback to embedded if an error happens when parsing file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3,embedded"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics))
|
||||
})
|
||||
@@ -115,10 +118,94 @@ var _ = Describe("sources", func() {
|
||||
It("should return nothing if error happens when trying to parse file", func() {
|
||||
conf.Server.LyricsPriority = ".mp3"
|
||||
|
||||
list, err := lyrics.GetLyrics(ctx, &mf)
|
||||
svc := lyrics.NewLyrics(nil)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("plugin sources", func() {
|
||||
var mockLoader *mockPluginLoader
|
||||
|
||||
BeforeEach(func() {
|
||||
mockLoader = &mockPluginLoader{}
|
||||
})
|
||||
|
||||
It("should return lyrics from a plugin", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
})
|
||||
|
||||
It("should try plugin after embedded returns nothing", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mf.Lyrics = "" // No embedded lyrics
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(unsyncedLyrics))
|
||||
})
|
||||
|
||||
It("should skip plugin if embedded has lyrics", func() {
|
||||
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
|
||||
mockLoader.lyrics = unsyncedLyrics
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
|
||||
})
|
||||
|
||||
It("should skip unknown plugin names gracefully", func() {
|
||||
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
|
||||
mockLoader.notFound = true
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
|
||||
It("should handle plugin error gracefully", func() {
|
||||
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
|
||||
mockLoader.err = fmt.Errorf("plugin error")
|
||||
svc := lyrics.NewLyrics(mockLoader)
|
||||
list, err := svc.GetLyrics(ctx, &mf)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPluginLoader struct {
|
||||
lyrics model.LyricList
|
||||
err error
|
||||
notFound bool
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) PluginNames(_ string) []string {
|
||||
if m.notFound {
|
||||
return nil
|
||||
}
|
||||
return []string{"test-lyrics-plugin"}
|
||||
}
|
||||
|
||||
func (m *mockPluginLoader) LoadLyricsProvider(_ string) (lyrics.Lyrics, bool) {
|
||||
if m.notFound {
|
||||
return nil, false
|
||||
}
|
||||
return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true
|
||||
}
|
||||
|
||||
type mockLyricsProvider struct {
|
||||
lyrics model.LyricList
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) {
|
||||
return m.lyrics, m.err
|
||||
}
|
||||
|
||||
@@ -49,3 +49,27 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
|
||||
return model.LyricList{*lyrics}, nil
|
||||
}
|
||||
|
||||
// fromPlugin attempts to load lyrics from a plugin with the given name.
|
||||
func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) {
|
||||
if l.pluginLoader == nil {
|
||||
log.Debug(ctx, "Invalid lyric source", "source", pluginName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName)
|
||||
if !ok {
|
||||
log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lyricsList, err := provider.GetLyrics(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(lyricsList) > 0 {
|
||||
log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList))
|
||||
}
|
||||
return lyricsList, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -28,4 +29,5 @@ var Set = wire.NewSet(
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
metrics.GetInstance,
|
||||
lyrics.NewLyrics,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE plugin DROP COLUMN allow_write_access;
|
||||
@@ -3,19 +3,20 @@ package model
|
||||
import "time"
|
||||
|
||||
type Plugin struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Manifest string `structs:"manifest" json:"manifest"`
|
||||
Config string `structs:"config" json:"config,omitempty"`
|
||||
Users string `structs:"users" json:"users,omitempty"`
|
||||
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||
Enabled bool `structs:"enabled" json:"enabled"`
|
||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||
SHA256 string `structs:"sha256" json:"sha256"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Manifest string `structs:"manifest" json:"manifest"`
|
||||
Config string `structs:"config" json:"config,omitempty"`
|
||||
Users string `structs:"users" json:"users,omitempty"`
|
||||
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||
AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
|
||||
Enabled bool `structs:"enabled" json:"enabled"`
|
||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||
SHA256 string `structs:"sha256" json:"sha256"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Plugins []Plugin
|
||||
|
||||
@@ -134,6 +134,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"has_rating": annotationBoolFilter("rating"),
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
|
||||
@@ -98,6 +98,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"has_rating": annotationBoolFilter("rating"),
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
|
||||
@@ -79,8 +79,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
|
||||
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||
_, err := r.db.NewQuery(`
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
manifest = excluded.manifest,
|
||||
@@ -89,24 +89,26 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
all_users = excluded.all_users,
|
||||
libraries = excluded.libraries,
|
||||
all_libraries = excluded.all_libraries,
|
||||
allow_write_access = excluded.allow_write_access,
|
||||
enabled = excluded.enabled,
|
||||
last_error = excluded.last_error,
|
||||
sha256 = excluded.sha256,
|
||||
updated_at = excluded.updated_at
|
||||
`).Bind(dbx.Params{
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"allow_write_access": plugin.AllowWriteAccess,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
26
plugins/capabilities/lyrics.go
Normal file
26
plugins/capabilities/lyrics.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package capabilities
|
||||
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
//
|
||||
//nd:capability name=lyrics required=true
|
||||
type Lyrics interface {
|
||||
//nd:export name=nd_lyrics_get_lyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
115
plugins/capabilities/lyrics.yaml
Normal file
115
plugins/capabilities/lyrics.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_lyrics_get_lyrics:
|
||||
input:
|
||||
$ref: '#/components/schemas/GetLyricsRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/GetLyricsResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
ArtistRef:
|
||||
description: ArtistRef is a reference to an artist with name and optional MBID.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID (if known).
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the artist.
|
||||
required:
|
||||
- name
|
||||
GetLyricsRequest:
|
||||
description: GetLyricsRequest contains the track information for lyrics lookup.
|
||||
properties:
|
||||
track:
|
||||
$ref: '#/components/schemas/TrackInfo'
|
||||
required:
|
||||
- track
|
||||
GetLyricsResponse:
|
||||
description: GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
properties:
|
||||
lyrics:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LyricsText'
|
||||
required:
|
||||
- lyrics
|
||||
LyricsText:
|
||||
description: |-
|
||||
LyricsText represents a single set of lyrics in raw text format.
|
||||
Text can be plain text or LRC format — Navidrome will parse it.
|
||||
properties:
|
||||
lang:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
required:
|
||||
- text
|
||||
TrackInfo:
|
||||
description: TrackInfo contains track metadata for scrobbling.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome track ID.
|
||||
title:
|
||||
type: string
|
||||
description: Title is the track title.
|
||||
album:
|
||||
type: string
|
||||
description: Album is the album name.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
albumArtist:
|
||||
type: string
|
||||
description: AlbumArtist is the formatted album artist name for display.
|
||||
artists:
|
||||
type: array
|
||||
description: Artists is the list of track artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
albumArtists:
|
||||
type: array
|
||||
description: AlbumArtists is the list of album artists.
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: Duration is the track duration in seconds.
|
||||
trackNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: TrackNumber is the track number on the album.
|
||||
discNumber:
|
||||
type: integer
|
||||
format: int32
|
||||
description: DiscNumber is the disc number.
|
||||
mbzRecordingId:
|
||||
type: string
|
||||
description: MBZRecordingID is the MusicBrainz recording ID.
|
||||
mbzAlbumId:
|
||||
type: string
|
||||
description: MBZAlbumID is the MusicBrainz album/release ID.
|
||||
mbzReleaseGroupId:
|
||||
type: string
|
||||
description: MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
mbzReleaseTrackId:
|
||||
type: string
|
||||
description: MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- album
|
||||
- artist
|
||||
- albumArtist
|
||||
- artists
|
||||
- albumArtists
|
||||
- duration
|
||||
- trackNumber
|
||||
- discNumber
|
||||
55
plugins/lyrics_adapter.go
Normal file
55
plugins/lyrics_adapter.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
)
|
||||
|
||||
const CapabilityLyrics Capability = "Lyrics"
|
||||
|
||||
const (
|
||||
FuncLyricsGetLyrics = "nd_lyrics_get_lyrics"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilityLyrics,
|
||||
FuncLyricsGetLyrics,
|
||||
)
|
||||
}
|
||||
|
||||
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
|
||||
type LyricsPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses
|
||||
// using model.ToLyrics.
|
||||
func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
|
||||
req := capabilities.GetLyricsRequest{
|
||||
Track: mediaFileToTrackInfo(mf),
|
||||
}
|
||||
resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse](
|
||||
ctx, l.plugin, FuncLyricsGetLyrics, req,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result model.LyricList
|
||||
for _, lt := range resp.Lyrics {
|
||||
parsed, err := model.ToLyrics(lt.Lang, lt.Text)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err)
|
||||
continue
|
||||
}
|
||||
if parsed != nil && !parsed.IsEmpty() {
|
||||
result = append(result, *parsed)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
84
plugins/lyrics_adapter_test.go
Normal file
84
plugins/lyrics_adapter_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LyricsPlugin", Ordered, func() {
|
||||
var (
|
||||
lyricsManager *Manager
|
||||
provider *LyricsPlugin
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
lyricsManager, _ = createTestManagerWithPlugins(nil,
|
||||
"test-lyrics"+PackageExtension,
|
||||
"test-metadata-agent"+PackageExtension,
|
||||
)
|
||||
|
||||
p, ok := lyricsManager.LoadLyricsProvider("test-lyrics")
|
||||
Expect(ok).To(BeTrue())
|
||||
provider = p.(*LyricsPlugin)
|
||||
})
|
||||
|
||||
Describe("LoadLyricsProvider", func() {
|
||||
It("returns a lyrics provider for a plugin with Lyrics capability", func() {
|
||||
Expect(provider).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns false for a plugin without Lyrics capability", func() {
|
||||
_, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for non-existent plugin", func() {
|
||||
_, ok := lyricsManager.LoadLyricsProvider("non-existent")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetLyrics", func() {
|
||||
It("successfully returns lyrics from the plugin", func() {
|
||||
track := &model.MediaFile{
|
||||
ID: "track-1",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
}
|
||||
|
||||
result, err := provider.GetLyrics(GinkgoT().Context(), track)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Line).ToNot(BeEmpty())
|
||||
Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song"))
|
||||
})
|
||||
|
||||
It("returns error when plugin returns error", func() {
|
||||
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||
"test-lyrics": {"error": "service unavailable"},
|
||||
}, "test-lyrics"+PackageExtension)
|
||||
|
||||
p, ok := manager.LoadLyricsProvider("test-lyrics")
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
|
||||
_, err := p.GetLyrics(GinkgoT().Context(), track)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PluginNames", func() {
|
||||
It("returns plugin names with Lyrics capability", func() {
|
||||
names := lyricsManager.PluginNames("Lyrics")
|
||||
Expect(names).To(ContainElement("test-lyrics"))
|
||||
})
|
||||
|
||||
It("does not return metadata agent plugins for Lyrics capability", func() {
|
||||
names := lyricsManager.PluginNames("Lyrics")
|
||||
Expect(names).ToNot(ContainElement("test-metadata-agent"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
extism "github.com/extism/go-sdk"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -276,6 +277,22 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
|
||||
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
|
||||
m.mu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &LyricsPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
}
|
||||
|
||||
// PluginInfo contains basic information about a plugin for metrics/insights.
|
||||
type PluginInfo struct {
|
||||
Name string
|
||||
@@ -428,10 +445,11 @@ func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, a
|
||||
// If the plugin is enabled, it will be reloaded with the new settings.
|
||||
// If the plugin requires library permission and no libraries are configured (and allLibraries is false),
|
||||
// the plugin will be automatically disabled.
|
||||
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
|
||||
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
|
||||
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
|
||||
p.Libraries = librariesJSON
|
||||
p.AllLibraries = allLibraries
|
||||
p.AllowWriteAccess = allowWriteAccess
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,8 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
|
||||
// loadPluginWithConfig loads a plugin with configuration from DB.
|
||||
// The p.Path should point to an .ndp package file.
|
||||
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
ctx := log.NewContext(m.ctx, "plugin", p.ID)
|
||||
|
||||
if m.stopped.Load() {
|
||||
return fmt.Errorf("manager is stopped")
|
||||
}
|
||||
@@ -283,27 +285,13 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
|
||||
// Configure filesystem access for library permission
|
||||
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
|
||||
adminCtx := adminContext(m.ctx)
|
||||
adminCtx := adminContext(ctx)
|
||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
|
||||
}
|
||||
|
||||
// Build a set of allowed library IDs for fast lookup
|
||||
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraries))
|
||||
for _, id := range allowedLibraries {
|
||||
allowedLibrarySet[id] = struct{}{}
|
||||
}
|
||||
|
||||
allowedPaths := make(map[string]string)
|
||||
for _, lib := range libraries {
|
||||
// Only mount if allLibraries is true or library is in the allowed list
|
||||
if p.AllLibraries {
|
||||
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
|
||||
} else if _, ok := allowedLibrarySet[lib.ID]; ok {
|
||||
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
|
||||
}
|
||||
}
|
||||
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
|
||||
pluginManifest.AllowedPaths = allowedPaths
|
||||
}
|
||||
|
||||
@@ -339,7 +327,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
// Enable experimental threads if requested in manifest
|
||||
if pkg.Manifest.HasExperimentalThreads() {
|
||||
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
|
||||
log.Debug(m.ctx, "Enabling experimental threads support", "plugin", p.ID)
|
||||
log.Debug(ctx, "Enabling experimental threads support")
|
||||
}
|
||||
|
||||
extismConfig := extism.PluginConfig{
|
||||
@@ -347,24 +335,24 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
RuntimeConfig: runtimeConfig,
|
||||
EnableHttpResponseHeaders: true,
|
||||
}
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling plugin: %w", err)
|
||||
}
|
||||
|
||||
// Create instance to detect capabilities
|
||||
instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
|
||||
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
|
||||
if err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
compiled.Close(ctx)
|
||||
return fmt.Errorf("creating instance: %w", err)
|
||||
}
|
||||
instance.SetLogger(extismLogger(p.ID))
|
||||
capabilities := detectCapabilities(instance)
|
||||
instance.Close(m.ctx)
|
||||
instance.Close(ctx)
|
||||
|
||||
// Validate manifest against detected capabilities
|
||||
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
compiled.Close(ctx)
|
||||
return fmt.Errorf("manifest validation: %w", err)
|
||||
}
|
||||
|
||||
@@ -383,7 +371,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
// Call plugin init function
|
||||
callPluginInit(m.ctx, m.plugins[p.ID])
|
||||
callPluginInit(ctx, m.plugins[p.ID])
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -414,3 +402,32 @@ func parsePluginConfig(configJSON string) (map[string]string, error) {
|
||||
}
|
||||
return pluginConfig, nil
|
||||
}
|
||||
|
||||
// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access.
|
||||
// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only.
|
||||
// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included.
|
||||
func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string {
|
||||
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
|
||||
for _, id := range allowedLibraryIDs {
|
||||
allowedLibrarySet[id] = struct{}{}
|
||||
}
|
||||
allowedPaths := make(map[string]string)
|
||||
for _, lib := range libraries {
|
||||
_, allowed := allowedLibrarySet[lib.ID]
|
||||
if allLibraries || allowed {
|
||||
mountPoint := toPluginMountPoint(int32(lib.ID))
|
||||
hostPath := lib.Path
|
||||
if !allowWriteAccess {
|
||||
hostPath = "ro:" + hostPath
|
||||
}
|
||||
allowedPaths[hostPath] = mountPoint
|
||||
log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath)
|
||||
}
|
||||
}
|
||||
if allowWriteAccess {
|
||||
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
|
||||
} else {
|
||||
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
|
||||
}
|
||||
return allowedPaths
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -58,3 +59,66 @@ var _ = Describe("parsePluginConfig", func() {
|
||||
Expect(result).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("buildAllowedPaths", func() {
|
||||
var libraries model.Libraries
|
||||
|
||||
BeforeEach(func() {
|
||||
libraries = model.Libraries{
|
||||
{ID: 1, Path: "/music/library1"},
|
||||
{ID: 2, Path: "/music/library2"},
|
||||
{ID: 3, Path: "/music/library3"},
|
||||
}
|
||||
})
|
||||
|
||||
Context("read-only (default)", func() {
|
||||
It("mounts all libraries with ro: prefix when allLibraries is true", func() {
|
||||
result := buildAllowedPaths(nil, libraries, nil, true, false)
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2"))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
|
||||
})
|
||||
|
||||
It("mounts only selected libraries with ro: prefix", func() {
|
||||
result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false)
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
|
||||
Expect(result).ToNot(HaveKey("ro:/music/library2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("read-write (allowWriteAccess=true)", func() {
|
||||
It("mounts all libraries without ro: prefix when allLibraries is true", func() {
|
||||
result := buildAllowedPaths(nil, libraries, nil, true, true)
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1"))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3"))
|
||||
})
|
||||
|
||||
It("mounts only selected libraries without ro: prefix", func() {
|
||||
result := buildAllowedPaths(nil, libraries, []int{2}, false, true)
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("returns empty map when no libraries match", func() {
|
||||
result := buildAllowedPaths(nil, libraries, []int{99}, false, false)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty map when libraries list is empty", func() {
|
||||
result := buildAllowedPaths(nil, nil, []int{1}, false, false)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty map when allLibraries is false and no IDs provided", func() {
|
||||
result := buildAllowedPaths(nil, libraries, nil, false, false)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
118
plugins/pdk/go/lyrics/lyrics.go
Normal file
118
plugins/pdk/go/lyrics/lyrics.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the Lyrics capability.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package lyrics
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
type ArtistRef struct {
|
||||
// ID is the internal Navidrome artist ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
// Title is the track title.
|
||||
Title string `json:"title"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album"`
|
||||
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
Artist string `json:"artist"`
|
||||
// AlbumArtist is the formatted album artist name for display.
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
// Artists is the list of track artists.
|
||||
Artists []ArtistRef `json:"artists"`
|
||||
// AlbumArtists is the list of album artists.
|
||||
AlbumArtists []ArtistRef `json:"albumArtists"`
|
||||
// Duration is the track duration in seconds.
|
||||
Duration float32 `json:"duration"`
|
||||
// TrackNumber is the track number on the album.
|
||||
TrackNumber int32 `json:"trackNumber"`
|
||||
// DiscNumber is the disc number.
|
||||
DiscNumber int32 `json:"discNumber"`
|
||||
// MBZRecordingID is the MusicBrainz recording ID.
|
||||
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
}
|
||||
|
||||
// Lyrics requires all methods to be implemented.
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
type Lyrics interface {
|
||||
// GetLyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
} // Internal implementation holders
|
||||
var (
|
||||
lyricsImpl func(GetLyricsRequest) (GetLyricsResponse, error)
|
||||
)
|
||||
|
||||
// Register registers a lyrics implementation.
|
||||
// All methods are required.
|
||||
func Register(impl Lyrics) {
|
||||
lyricsImpl = impl.GetLyrics
|
||||
}
|
||||
|
||||
// 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_lyrics_get_lyrics
|
||||
func _NdLyricsGetLyrics() int32 {
|
||||
if lyricsImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
var input GetLyricsRequest
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
output, err := lyricsImpl(input)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
82
plugins/pdk/go/lyrics/lyrics_stub.go
Normal file
82
plugins/pdk/go/lyrics/lyrics_stub.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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 lyrics
|
||||
|
||||
// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
type ArtistRef struct {
|
||||
// ID is the internal Navidrome artist ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the artist.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
}
|
||||
|
||||
// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
type GetLyricsRequest struct {
|
||||
Track TrackInfo `json:"track"`
|
||||
}
|
||||
|
||||
// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
type GetLyricsResponse struct {
|
||||
Lyrics []LyricsText `json:"lyrics"`
|
||||
}
|
||||
|
||||
// LyricsText represents a single set of lyrics in raw text format.
|
||||
// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
type LyricsText struct {
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TrackInfo contains track metadata for scrobbling.
|
||||
type TrackInfo struct {
|
||||
// ID is the internal Navidrome track ID.
|
||||
ID string `json:"id"`
|
||||
// Title is the track title.
|
||||
Title string `json:"title"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album"`
|
||||
// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
Artist string `json:"artist"`
|
||||
// AlbumArtist is the formatted album artist name for display.
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
// Artists is the list of track artists.
|
||||
Artists []ArtistRef `json:"artists"`
|
||||
// AlbumArtists is the list of album artists.
|
||||
AlbumArtists []ArtistRef `json:"albumArtists"`
|
||||
// Duration is the track duration in seconds.
|
||||
Duration float32 `json:"duration"`
|
||||
// TrackNumber is the track number on the album.
|
||||
TrackNumber int32 `json:"trackNumber"`
|
||||
// DiscNumber is the disc number.
|
||||
DiscNumber int32 `json:"discNumber"`
|
||||
// MBZRecordingID is the MusicBrainz recording ID.
|
||||
MBZRecordingID string `json:"mbzRecordingId,omitempty"`
|
||||
// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
MBZAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
}
|
||||
|
||||
// Lyrics requires all methods to be implemented.
|
||||
// Lyrics provides lyrics for a given track from external sources.
|
||||
type Lyrics interface {
|
||||
// GetLyrics
|
||||
GetLyrics(GetLyricsRequest) (GetLyricsResponse, 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(_ Lyrics) {}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! for implementing Navidrome plugin capabilities in Rust.
|
||||
|
||||
pub mod lifecycle;
|
||||
pub mod lyrics;
|
||||
pub mod metadata;
|
||||
pub mod scheduler;
|
||||
pub mod scrobbler;
|
||||
|
||||
148
plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs
Normal file
148
plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the Lyrics 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 }
|
||||
/// ArtistRef is a reference to an artist with name and optional MBID.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArtistRef {
|
||||
/// ID is the internal Navidrome artist ID (if known).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub id: String,
|
||||
/// Name is the artist name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// MBID is the MusicBrainz ID for the artist.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbid: String,
|
||||
}
|
||||
/// GetLyricsRequest contains the track information for lyrics lookup.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetLyricsRequest {
|
||||
#[serde(default)]
|
||||
pub track: TrackInfo,
|
||||
}
|
||||
/// GetLyricsResponse contains the lyrics returned by the plugin.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetLyricsResponse {
|
||||
#[serde(default)]
|
||||
pub lyrics: Vec<LyricsText>,
|
||||
}
|
||||
/// LyricsText represents a single set of lyrics in raw text format.
|
||||
/// Text can be plain text or LRC format — Navidrome will parse it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LyricsText {
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub lang: String,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
}
|
||||
/// TrackInfo contains track metadata for scrobbling.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackInfo {
|
||||
/// ID is the internal Navidrome track ID.
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// Title is the track title.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// Album is the album name.
|
||||
#[serde(default)]
|
||||
pub album: String,
|
||||
/// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2").
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
/// AlbumArtist is the formatted album artist name for display.
|
||||
#[serde(default)]
|
||||
pub album_artist: String,
|
||||
/// Artists is the list of track artists.
|
||||
#[serde(default)]
|
||||
pub artists: Vec<ArtistRef>,
|
||||
/// AlbumArtists is the list of album artists.
|
||||
#[serde(default)]
|
||||
pub album_artists: Vec<ArtistRef>,
|
||||
/// Duration is the track duration in seconds.
|
||||
#[serde(default)]
|
||||
pub duration: f32,
|
||||
/// TrackNumber is the track number on the album.
|
||||
#[serde(default)]
|
||||
pub track_number: i32,
|
||||
/// DiscNumber is the disc number.
|
||||
#[serde(default)]
|
||||
pub disc_number: i32,
|
||||
/// MBZRecordingID is the MusicBrainz recording ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_recording_id: String,
|
||||
/// MBZAlbumID is the MusicBrainz album/release ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_album_id: String,
|
||||
/// MBZReleaseGroupID is the MusicBrainz release group ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_group_id: String,
|
||||
/// MBZReleaseTrackID is the MusicBrainz release track ID.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbz_release_track_id: String,
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Lyrics requires all methods to be implemented.
|
||||
/// Lyrics provides lyrics for a given track from external sources.
|
||||
pub trait Lyrics {
|
||||
/// GetLyrics
|
||||
fn get_lyrics(&self, req: GetLyricsRequest) -> Result<GetLyricsResponse, Error>;
|
||||
}
|
||||
|
||||
/// Register all exports for the Lyrics capability.
|
||||
/// This macro generates the WASM export functions for all trait methods.
|
||||
#[macro_export]
|
||||
macro_rules! register_lyrics {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_lyrics_get_lyrics(
|
||||
req: extism_pdk::Json<$crate::lyrics::GetLyricsRequest>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::lyrics::GetLyricsResponse>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?;
|
||||
Ok(extism_pdk::Json(result))
|
||||
}
|
||||
};
|
||||
}
|
||||
16
plugins/testdata/test-lyrics/go.mod
vendored
Normal file
16
plugins/testdata/test-lyrics/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
module test-lyrics
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/extism/go-pdk v1.1.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
14
plugins/testdata/test-lyrics/go.sum
vendored
Normal file
14
plugins/testdata/test-lyrics/go.sum
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
35
plugins/testdata/test-lyrics/main.go
vendored
Normal file
35
plugins/testdata/test-lyrics/main.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
// Test lyrics plugin for Navidrome plugin system integration tests.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/lyrics"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
lyrics.Register(&testLyrics{})
|
||||
}
|
||||
|
||||
type testLyrics struct{}
|
||||
|
||||
func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) {
|
||||
// Check for configured error
|
||||
errMsg, hasErr := pdk.GetConfig("error")
|
||||
if hasErr && errMsg != "" {
|
||||
return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Return test lyrics based on track info
|
||||
return lyrics.GetLyricsResponse{
|
||||
Lyrics: []lyrics.LyricsText{
|
||||
{
|
||||
Lang: "eng",
|
||||
Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
6
plugins/testdata/test-lyrics/manifest.json
vendored
Normal file
6
plugins/testdata/test-lyrics/manifest.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Test Lyrics",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A test lyrics plugin for integration testing"
|
||||
}
|
||||
@@ -353,7 +353,8 @@
|
||||
"allUsers": "Permitir todos os usuários",
|
||||
"selectedUsers": "Usuários selecionados",
|
||||
"allLibraries": "Permitir todas as bibliotecas",
|
||||
"selectedLibraries": "Bibliotecas selecionadas"
|
||||
"selectedLibraries": "Bibliotecas selecionadas",
|
||||
"allowWriteAccess": "Permitir acesso de escrita"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@@ -396,6 +397,7 @@
|
||||
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
|
||||
"noLibraries": "Nenhuma biblioteca selecionada",
|
||||
"librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.",
|
||||
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.",
|
||||
"requiredHosts": "Hosts necessários",
|
||||
"configValidationError": "Falha na validação da configuração:",
|
||||
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido."
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -396,6 +397,7 @@ func setupTestDB() {
|
||||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(nil),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ type PluginManager interface {
|
||||
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
|
||||
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
|
||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error
|
||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
RescanPlugins(ctx context.Context) error
|
||||
UnloadDisabledPlugins(ctx context.Context)
|
||||
}
|
||||
|
||||
@@ -56,12 +56,13 @@ func pluginsEnabledMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
// PluginUpdateRequest represents the fields that can be updated via the API
|
||||
type PluginUpdateRequest struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Config *string `json:"config,omitempty"`
|
||||
Users *string `json:"users,omitempty"`
|
||||
AllUsers *bool `json:"allUsers,omitempty"`
|
||||
Libraries *string `json:"libraries,omitempty"`
|
||||
AllLibraries *bool `json:"allLibraries,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Config *string `json:"config,omitempty"`
|
||||
Users *string `json:"users,omitempty"`
|
||||
AllUsers *bool `json:"allUsers,omitempty"`
|
||||
Libraries *string `json:"libraries,omitempty"`
|
||||
AllLibraries *bool `json:"allLibraries,omitempty"`
|
||||
AllowWriteAccess *bool `json:"allowWriteAccess,omitempty"`
|
||||
}
|
||||
|
||||
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,7 +110,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Handle libraries permission update (if provided)
|
||||
if req.Libraries != nil || req.AllLibraries != nil {
|
||||
if req.Libraries != nil || req.AllLibraries != nil || req.AllowWriteAccess != nil {
|
||||
if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil {
|
||||
log.Error(ctx, "Error updating plugin libraries", err)
|
||||
return
|
||||
@@ -245,6 +246,7 @@ func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo mode
|
||||
|
||||
librariesJSON := plugin.Libraries
|
||||
allLibraries := plugin.AllLibraries
|
||||
allowWriteAccess := plugin.AllowWriteAccess
|
||||
|
||||
if req.Libraries != nil {
|
||||
if *req.Libraries != "" && !isValidJSON(*req.Libraries) {
|
||||
@@ -256,8 +258,11 @@ func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo mode
|
||||
if req.AllLibraries != nil {
|
||||
allLibraries = *req.AllLibraries
|
||||
}
|
||||
if req.AllowWriteAccess != nil {
|
||||
allowWriteAccess = *req.AllowWriteAccess
|
||||
}
|
||||
|
||||
if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries); err != nil {
|
||||
if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries, allowWriteAccess); err != nil {
|
||||
log.Error(ctx, "Error updating plugin libraries", "id", id, err)
|
||||
http.Error(w, "Error updating plugin libraries: "+err.Error(), http.StatusInternalServerError)
|
||||
return err
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
@@ -48,12 +49,13 @@ type Router struct {
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
lyrics lyrics.Lyrics
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics,
|
||||
metrics metrics.Metrics, lyrics lyrics.Lyrics,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
@@ -69,6 +71,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
lyrics: lyrics,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playTracker = &fakePlayTracker{}
|
||||
eventBroker = &fakeEventBroker{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
@@ -109,7 +108,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), &mediaFiles[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +141,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
|
||||
structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -33,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil))
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
||||
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
@@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playlists = &fakePlaylists{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Get references to the mock repositories so we can inspect their Options
|
||||
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
||||
|
||||
@@ -18,7 +18,7 @@ type MockPluginManager struct {
|
||||
// UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError.
|
||||
UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error
|
||||
// UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError.
|
||||
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries bool) error
|
||||
UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
||||
// RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError.
|
||||
RescanPluginsFn func(ctx context.Context) error
|
||||
|
||||
@@ -48,9 +48,10 @@ type MockPluginManager struct {
|
||||
AllUsers bool
|
||||
}
|
||||
UpdatePluginLibrariesCalls []struct {
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
AllowWriteAccess bool
|
||||
}
|
||||
RescanPluginsCalls int
|
||||
}
|
||||
@@ -105,14 +106,15 @@ func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON
|
||||
return m.UsersError
|
||||
}
|
||||
|
||||
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
|
||||
func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
|
||||
m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct {
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries})
|
||||
ID string
|
||||
LibrariesJSON string
|
||||
AllLibraries bool
|
||||
AllowWriteAccess bool
|
||||
}{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries, AllowWriteAccess: allowWriteAccess})
|
||||
if m.UpdatePluginLibrariesFn != nil {
|
||||
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries)
|
||||
return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries, allowWriteAccess)
|
||||
}
|
||||
return m.LibrariesError
|
||||
}
|
||||
|
||||
@@ -355,7 +355,8 @@
|
||||
"allUsers": "Allow all users",
|
||||
"selectedUsers": "Selected users",
|
||||
"allLibraries": "Allow all libraries",
|
||||
"selectedLibraries": "Selected libraries"
|
||||
"selectedLibraries": "Selected libraries",
|
||||
"allowWriteAccess": "Allow write access"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
@@ -400,6 +401,7 @@
|
||||
"allLibrariesHelp": "When enabled, the plugin will have access to all libraries, including those created in the future.",
|
||||
"noLibraries": "No libraries selected",
|
||||
"librariesRequired": "This plugin requires access to library information. Select which libraries the plugin can access, or enable 'Allow all libraries'.",
|
||||
"allowWriteAccessHelp": "When enabled, the plugin can modify files in the library directories. By default, plugins have read-only access.",
|
||||
"requiredHosts": "Required hosts"
|
||||
},
|
||||
"placeholders": {
|
||||
|
||||
@@ -23,8 +23,10 @@ export const LibraryPermissionCard = ({
|
||||
classes,
|
||||
selectedLibraries,
|
||||
allLibraries,
|
||||
allowWriteAccess,
|
||||
onSelectedLibrariesChange,
|
||||
onAllLibrariesChange,
|
||||
onAllowWriteAccessChange,
|
||||
}) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
@@ -58,9 +60,17 @@ export const LibraryPermissionCard = ({
|
||||
[onAllLibrariesChange],
|
||||
)
|
||||
|
||||
const handleAllowWriteAccessToggle = React.useCallback(
|
||||
(event) => {
|
||||
onAllowWriteAccessChange(event.target.checked)
|
||||
},
|
||||
[onAllowWriteAccessChange],
|
||||
)
|
||||
|
||||
// Get permission reason from manifest
|
||||
const libraryPermission = manifest?.permissions?.library
|
||||
const reason = libraryPermission?.reason
|
||||
const hasFilesystem = libraryPermission?.filesystem === true
|
||||
|
||||
// Check if permission is required but not configured
|
||||
const isConfigurationRequired =
|
||||
@@ -107,6 +117,24 @@ export const LibraryPermissionCard = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hasFilesystem && (
|
||||
<Box mb={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={allowWriteAccess}
|
||||
onChange={handleAllowWriteAccessToggle}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label={translate('resources.plugin.fields.allowWriteAccess')}
|
||||
/>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{translate('resources.plugin.messages.allowWriteAccessHelp')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!allLibraries && (
|
||||
<Box className={classes.usersList}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
@@ -166,6 +194,8 @@ LibraryPermissionCard.propTypes = {
|
||||
classes: PropTypes.object.isRequired,
|
||||
selectedLibraries: PropTypes.array.isRequired,
|
||||
allLibraries: PropTypes.bool.isRequired,
|
||||
allowWriteAccess: PropTypes.bool.isRequired,
|
||||
onSelectedLibrariesChange: PropTypes.func.isRequired,
|
||||
onAllLibrariesChange: PropTypes.func.isRequired,
|
||||
onAllowWriteAccessChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
@@ -48,8 +48,11 @@ const PluginShowLayout = () => {
|
||||
// Libraries permission state
|
||||
const [selectedLibraries, setSelectedLibraries] = useState([])
|
||||
const [allLibraries, setAllLibraries] = useState(false)
|
||||
const [allowWriteAccess, setAllowWriteAccess] = useState(false)
|
||||
const [lastRecordLibraries, setLastRecordLibraries] = useState(null)
|
||||
const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null)
|
||||
const [lastRecordAllowWriteAccess, setLastRecordAllowWriteAccess] =
|
||||
useState(null)
|
||||
|
||||
// Parse JSON config to object
|
||||
const jsonToObject = useCallback((jsonString) => {
|
||||
@@ -99,10 +102,12 @@ const PluginShowLayout = () => {
|
||||
if (record && !isDirty) {
|
||||
const recordLibraries = record.libraries || ''
|
||||
const recordAllLibraries = record.allLibraries || false
|
||||
const recordAllowWriteAccess = record.allowWriteAccess || false
|
||||
|
||||
if (
|
||||
recordLibraries !== lastRecordLibraries ||
|
||||
recordAllLibraries !== lastRecordAllLibraries
|
||||
recordAllLibraries !== lastRecordAllLibraries ||
|
||||
recordAllowWriteAccess !== lastRecordAllowWriteAccess
|
||||
) {
|
||||
try {
|
||||
setSelectedLibraries(
|
||||
@@ -112,11 +117,19 @@ const PluginShowLayout = () => {
|
||||
setSelectedLibraries([])
|
||||
}
|
||||
setAllLibraries(recordAllLibraries)
|
||||
setAllowWriteAccess(recordAllowWriteAccess)
|
||||
setLastRecordLibraries(recordLibraries)
|
||||
setLastRecordAllLibraries(recordAllLibraries)
|
||||
setLastRecordAllowWriteAccess(recordAllowWriteAccess)
|
||||
}
|
||||
}
|
||||
}, [record, lastRecordLibraries, lastRecordAllLibraries, isDirty])
|
||||
}, [
|
||||
record,
|
||||
lastRecordLibraries,
|
||||
lastRecordAllLibraries,
|
||||
lastRecordAllowWriteAccess,
|
||||
isDirty,
|
||||
])
|
||||
|
||||
const handleConfigDataChange = useCallback(
|
||||
(newData, errors) => {
|
||||
@@ -152,6 +165,11 @@ const PluginShowLayout = () => {
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const handleAllowWriteAccessChange = useCallback((newAllowWriteAccess) => {
|
||||
setAllowWriteAccess(newAllowWriteAccess)
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const [updatePlugin, { loading }] = useUpdate(
|
||||
'plugin',
|
||||
record?.id,
|
||||
@@ -167,6 +185,7 @@ const PluginShowLayout = () => {
|
||||
setLastRecordAllUsers(null)
|
||||
setLastRecordLibraries(null)
|
||||
setLastRecordAllLibraries(null)
|
||||
setLastRecordAllowWriteAccess(null)
|
||||
notify('resources.plugin.notifications.updated', 'info')
|
||||
},
|
||||
onFailure: (err) => {
|
||||
@@ -199,6 +218,7 @@ const PluginShowLayout = () => {
|
||||
if (parsedManifest?.permissions?.library) {
|
||||
data.libraries = JSON.stringify(selectedLibraries)
|
||||
data.allLibraries = allLibraries
|
||||
data.allowWriteAccess = allowWriteAccess
|
||||
}
|
||||
|
||||
updatePlugin('plugin', record.id, data, record)
|
||||
@@ -210,6 +230,7 @@ const PluginShowLayout = () => {
|
||||
allUsers,
|
||||
selectedLibraries,
|
||||
allLibraries,
|
||||
allowWriteAccess,
|
||||
])
|
||||
|
||||
// Parse manifest
|
||||
@@ -294,8 +315,10 @@ const PluginShowLayout = () => {
|
||||
classes={classes}
|
||||
selectedLibraries={selectedLibraries}
|
||||
allLibraries={allLibraries}
|
||||
allowWriteAccess={allowWriteAccess}
|
||||
onSelectedLibrariesChange={handleSelectedLibrariesChange}
|
||||
onAllLibrariesChange={handleAllLibrariesChange}
|
||||
onAllowWriteAccessChange={handleAllowWriteAccessChange}
|
||||
/>
|
||||
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
|
||||
Reference in New Issue
Block a user