mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-14 08:51:13 -05:00
Compare commits
18 Commits
refactor/p
...
plugins-en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c260db60c | ||
|
|
fc113d1dc6 | ||
|
|
425fe862ba | ||
|
|
b1a51f9bbe | ||
|
|
9a004fd043 | ||
|
|
5c52bbb130 | ||
|
|
b0f91715b9 | ||
|
|
9f7b6870ac | ||
|
|
f00af7f983 | ||
|
|
875ffc2b78 | ||
|
|
885334c819 | ||
|
|
ff86b9f2b9 | ||
|
|
13d3d510f5 | ||
|
|
656009e5f8 | ||
|
|
06b3a1f33e | ||
|
|
0f4e8376cb | ||
|
|
199cde4109 | ||
|
|
897de02a84 |
138
.github/workflows/push-translations.sh
vendored
Executable file
138
.github/workflows/push-translations.sh
vendored
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
I18N_DIR=resources/i18n
|
||||
|
||||
# Normalize JSON for deterministic comparison:
|
||||
# remove empty/null attributes, sort keys alphabetically
|
||||
process_json() {
|
||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||
}
|
||||
|
||||
# Get list of all languages configured in the POEditor project
|
||||
get_language_list() {
|
||||
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}"
|
||||
}
|
||||
|
||||
# Extract language name from the language list JSON given a language code
|
||||
get_language_name() {
|
||||
lang_code="$1"
|
||||
lang_list="$2"
|
||||
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
||||
}
|
||||
|
||||
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
||||
get_lang_code() {
|
||||
filepath="$1"
|
||||
filename=$(basename "$filepath")
|
||||
echo "${filename%.*}"
|
||||
}
|
||||
|
||||
# Export the current translation for a language from POEditor (v2 API)
|
||||
export_language() {
|
||||
lang_code="$1"
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
-d type="key_value_json")
|
||||
|
||||
url=$(echo "$response" | jq -r '.result.url')
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Failed to export $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$url"
|
||||
}
|
||||
|
||||
# Flatten nested JSON to POEditor languages/update format.
|
||||
# POEditor uses term + context pairs, where:
|
||||
# term = the leaf key name
|
||||
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
||||
flatten_to_poeditor() {
|
||||
jq -c '[paths(scalars) as $p |
|
||||
{
|
||||
"term": ($p | last | tostring),
|
||||
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
||||
"translation": {"content": getpath($p)}
|
||||
}
|
||||
]' "$1"
|
||||
}
|
||||
|
||||
# Update translations for a language in POEditor via languages/update API
|
||||
update_language() {
|
||||
lang_code="$1"
|
||||
file="$2"
|
||||
|
||||
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
--data-urlencode data@/tmp/poeditor_data.json)
|
||||
rm -f /tmp/poeditor_data.json
|
||||
|
||||
status=$(echo "$response" | jq -r '.response.status')
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "Failed to update $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
||||
added=$(echo "$response" | jq -r '.result.translations.added')
|
||||
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
||||
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <file1> [file2] ..."
|
||||
echo "No files specified. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
lang_list=$(get_language_list)
|
||||
upload_count=0
|
||||
|
||||
for file in "$@"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Warning: File not found: $file, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
lang_code=$(get_lang_code "$file")
|
||||
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
||||
|
||||
if [ -z "$lang_name" ]; then
|
||||
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing $lang_name ($lang_code)..."
|
||||
|
||||
# Export current state from POEditor
|
||||
url=$(export_language "$lang_code")
|
||||
curl -sSL "$url" -o poeditor_export.json
|
||||
|
||||
# Normalize both files for comparison
|
||||
process_json "$file" > local_normalized.json
|
||||
process_json poeditor_export.json > remote_normalized.json
|
||||
|
||||
# Compare normalized versions
|
||||
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
||||
echo " No differences, skipping"
|
||||
else
|
||||
echo " Differences found, updating POEditor..."
|
||||
update_language "$lang_code" "$file"
|
||||
upload_count=$((upload_count + 1))
|
||||
fi
|
||||
|
||||
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. Updated $upload_count translation(s) in POEditor."
|
||||
32
.github/workflows/push-translations.yml
vendored
Normal file
32
.github/workflows/push-translations.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: POEditor export
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'resources/i18n/*.json'
|
||||
|
||||
jobs:
|
||||
push-translations:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changed translation files
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
|
||||
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
|
||||
echo "Changed translation files: $CHANGED_FILES"
|
||||
|
||||
- name: Push translations to POEditor
|
||||
if: ${{ steps.changed.outputs.files != '' }}
|
||||
env:
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
run: |
|
||||
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
|
||||
10
cmd/root.go
10
cmd/root.go
@@ -14,10 +14,13 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/backgrounds"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -138,6 +141,13 @@ func startServer(ctx context.Context) func() error {
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
}
|
||||
if conf.Server.Plugins.Enabled {
|
||||
manager := GetPluginManager(ctx)
|
||||
ds := CreateDataStore()
|
||||
endpointRouter := plugins.NewEndpointRouter(manager, ds, subsonic.ValidateAuth, server.Authenticator)
|
||||
a.MountRouter("Plugin Endpoints", consts.URLPathPluginEndpoints, endpointRouter)
|
||||
a.MountRouter("Plugin Subsonic Endpoints", consts.URLPathPluginSubsonicEndpoints, endpointRouter)
|
||||
}
|
||||
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -74,7 +74,7 @@ func runScanner(ctx context.Context) {
|
||||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -62,7 +61,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
@@ -73,12 +72,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -99,11 +98,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, 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)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -166,8 +165,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
@@ -183,8 +182,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
|
||||
@@ -239,11 +239,13 @@ type inspectOptions struct {
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
EndpointRequestLimit int
|
||||
EndpointRequestWindow time.Duration
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
@@ -671,6 +673,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
viper.SetDefault("plugins.endpointrequestlimit", 60)
|
||||
viper.SetDefault("plugins.endpointrequestwindow", time.Minute)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
|
||||
@@ -36,11 +36,13 @@ const (
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
||||
URLPathUI = "/app"
|
||||
URLPathNativeAPI = "/api"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathUI = "/app"
|
||||
URLPathNativeAPI = "/api"
|
||||
URLPathSubsonicAPI = "/rest"
|
||||
URLPathPluginEndpoints = "/ext"
|
||||
URLPathPluginSubsonicEndpoints = "/rest/ext"
|
||||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
|
||||
// DefaultUILoginBackgroundURL uses Navidrome curated background images collection,
|
||||
// available at https://unsplash.com/collections/20072696/navidrome
|
||||
|
||||
@@ -220,7 +220,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
|
||||
@@ -1,28 +1,183 @@
|
||||
package playlists
|
||||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func InPlaylistsPath(folder model.Folder) bool {
|
||||
if conf.Server.PlaylistsPath == "" {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mfs model.MediaFiles
|
||||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||||
@@ -47,7 +202,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
||||
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||
continue
|
||||
@@ -103,9 +258,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
existing[key] = idx
|
||||
}
|
||||
|
||||
// Find media files in the order of the resolved paths, to keep playlist order.
|
||||
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
|
||||
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
for _, path := range resolvedPaths {
|
||||
key := strings.ToLower(norm.NFC.String(path))
|
||||
idx, ok := existing[key]
|
||||
@@ -245,10 +398,15 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]string, 0, len(lines))
|
||||
for idx, line := range lines {
|
||||
resolution := r.resolvePath(line, folder)
|
||||
resolution := resolver.resolvePath(line, folder)
|
||||
|
||||
if !resolution.valid {
|
||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||
@@ -267,3 +425,123 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
needsInfoUpdate := name != nil || comment != nil || public != nil
|
||||
needsTrackRefresh := len(idxToRemove) > 0
|
||||
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistID, true)
|
||||
if tracks == nil {
|
||||
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||
}
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true, false)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddMediaFilesByID(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if needsInfoUpdate {
|
||||
pls, err = repo.Get(playlistID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needsTrackRefresh && !needsInfoUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
// Special case: The playlist is now empty
|
||||
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||
if err = tracks.DeleteAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repo.Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("parseNSP", func() {
|
||||
var s *playlists
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("parses a well-formed NSP with all fields", func() {
|
||||
nsp := `{
|
||||
"name": "My Smart Playlist",
|
||||
"comment": "A test playlist",
|
||||
"public": true,
|
||||
"all": [{"is": {"loved": true}}],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 50
|
||||
}`
|
||||
pls := &model.Playlist{Name: "default-name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Smart Playlist"))
|
||||
Expect(pls.Comment).To(Equal("A test playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("title"))
|
||||
Expect(pls.Rules.Order).To(Equal("asc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(50))
|
||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||
})
|
||||
|
||||
It("keeps existing name when NSP has no name field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original Name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("keeps existing comment when NSP has no comment field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Comment: "Original Comment"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Comment).To(Equal("Original Comment"))
|
||||
})
|
||||
|
||||
It("strips JSON comments before parsing", func() {
|
||||
nsp := `{
|
||||
// Line comment
|
||||
"name": "Commented Playlist",
|
||||
/* Block comment */
|
||||
"all": [{"is": {"loved": true}}]
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Commented Playlist"))
|
||||
})
|
||||
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("honors explicit public: false over server default", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns a syntax error with line and column info", func() {
|
||||
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
|
||||
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
|
||||
})
|
||||
|
||||
It("returns a parsing error for completely invalid JSON", func() {
|
||||
nsp := `not json at all`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
|
||||
})
|
||||
|
||||
It("gracefully handles non-string name field", func() {
|
||||
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Type assertion in UnmarshalJSON fails silently; name stays as original
|
||||
Expect(pls.Name).To(Equal("Original"))
|
||||
})
|
||||
|
||||
It("parses criteria with multiple rules", func() {
|
||||
nsp := `{
|
||||
"all": [
|
||||
{"is": {"loved": true}},
|
||||
{"contains": {"title": "rock"}}
|
||||
],
|
||||
"sort": "lastPlayed",
|
||||
"order": "desc",
|
||||
"limit": 100
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(100))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("getPositionFromOffset", func() {
|
||||
It("returns correct position on first line", func() {
|
||||
data := []byte("hello world")
|
||||
line, col := getPositionFromOffset(data, 5)
|
||||
Expect(line).To(Equal(1))
|
||||
Expect(col).To(Equal(5))
|
||||
})
|
||||
|
||||
It("returns correct position after newlines", func() {
|
||||
data := []byte("line1\nline2\nline3")
|
||||
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
|
||||
line, col := getPositionFromOffset(data, 8)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(3))
|
||||
})
|
||||
|
||||
It("returns correct position at start of new line", func() {
|
||||
data := []byte("line1\nline2")
|
||||
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
|
||||
It("handles multiple newlines", func() {
|
||||
data := []byte("a\nb\nc\nd")
|
||||
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(4))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("newSyncedPlaylist", func() {
|
||||
var s *playlists
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("creates a synced playlist with correct attributes", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("test"))
|
||||
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
Expect(pls.UpdatedAt).ToNot(BeZero())
|
||||
})
|
||||
|
||||
It("strips extension from filename to derive name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Favorites"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
// Reads
|
||||
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
||||
Get(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
||||
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
||||
|
||||
// Mutations
|
||||
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
|
||||
// Track management
|
||||
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
|
||||
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
|
||||
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
|
||||
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
|
||||
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
|
||||
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
|
||||
|
||||
// Import
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
|
||||
// REST adapters (follows Share/Library pattern)
|
||||
NewRepository(ctx context.Context) rest.Repository
|
||||
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func InPath(folder model.Folder) bool {
|
||||
if conf.Server.PlaylistsPath == "" {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Read operations ---
|
||||
|
||||
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
|
||||
return s.ds.Playlist(ctx).GetAll(options...)
|
||||
}
|
||||
|
||||
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
return s.ds.Playlist(ctx).Get(id)
|
||||
}
|
||||
|
||||
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
}
|
||||
|
||||
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
|
||||
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
|
||||
}
|
||||
|
||||
// --- Mutation operations ---
|
||||
|
||||
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
||||
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
|
||||
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = usr.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
return err
|
||||
})
|
||||
return playlistId, err
|
||||
}
|
||||
|
||||
func (s *playlists) Delete(ctx context.Context, id string) error {
|
||||
if _, err := s.checkWritable(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Delete(id)
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
|
||||
if hasTrackChanges {
|
||||
pls, err = s.checkTracksEditable(ctx, playlistID)
|
||||
} else {
|
||||
pls, err = s.checkWritable(ctx, playlistID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
repo := tx.Playlist(ctx)
|
||||
|
||||
if len(idxToRemove) > 0 {
|
||||
tracksRepo := repo.Tracks(playlistID, false)
|
||||
// Convert 0-based indices to 1-based position IDs and delete them directly,
|
||||
// avoiding the need to load all tracks into memory.
|
||||
positions := make([]string, len(idxToRemove))
|
||||
for i, idx := range idxToRemove {
|
||||
positions[i] = strconv.Itoa(idx + 1)
|
||||
}
|
||||
if err := tracksRepo.Delete(positions...); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(idsToAdd) > 0 {
|
||||
if _, err := tracksRepo.Add(idsToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
||||
}
|
||||
|
||||
if len(idsToAdd) > 0 {
|
||||
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if name == nil && comment == nil && public == nil {
|
||||
return nil
|
||||
}
|
||||
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
|
||||
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Permission helpers ---
|
||||
|
||||
// checkWritable fetches the playlist and verifies the current user can modify it.
|
||||
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
|
||||
pls, err := s.ds.Playlist(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
|
||||
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pls.IsSmartPlaylist() {
|
||||
return nil, model.ErrNotAuthorized
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
// updateMetadata applies optional metadata changes to a playlist and persists it.
|
||||
// Accepts a DataStore parameter so it can be used inside transactions.
|
||||
// The caller is responsible for permission checks.
|
||||
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
return ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
// --- Track management operations ---
|
||||
|
||||
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
|
||||
}
|
||||
|
||||
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
|
||||
}
|
||||
|
||||
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
|
||||
}
|
||||
|
||||
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
|
||||
}
|
||||
|
||||
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
|
||||
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
|
||||
})
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPlaylists(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Playlists Suite")
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to delete their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("allows admin to delete any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from deleting", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(mockPlsRepo.Deleted).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "nonexistent")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
|
||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("creates a new playlist with owner set from context", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
|
||||
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("replaces tracks on existing playlist when owner matches", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-1"))
|
||||
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("allows admin to replace tracks on any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-2"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when existing playlistId not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies replacing tracks on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from updating", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies adding tracks to a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies removing tracks from a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Smart"
|
||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to add tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
|
||||
})
|
||||
|
||||
It("allows admin to add tracks to any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies editing smart playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to remove tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReorderTrack", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to reorder", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.Reordered).To(BeTrue())
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
|
||||
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
|
||||
return &playlistRepositoryWrapper{
|
||||
ctx: ctx,
|
||||
PlaylistRepository: s.ds.Playlist(ctx),
|
||||
service: s,
|
||||
}
|
||||
}
|
||||
|
||||
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
|
||||
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
|
||||
// and rest.Persistable by delegating to service methods for all mutations.
|
||||
type playlistRepositoryWrapper struct {
|
||||
model.PlaylistRepository
|
||||
ctx context.Context
|
||||
service *playlists
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
err := r.service.Delete(r.ctx, id)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
|
||||
repo := s.ds.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
|
||||
if tracks == nil {
|
||||
return nil
|
||||
}
|
||||
return tracks.(rest.Repository)
|
||||
}
|
||||
|
||||
// savePlaylist creates a new playlist, assigning the owner from context.
|
||||
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
pls.OwnerID = usr.ID
|
||||
pls.ID = "" // Force new creation
|
||||
err := s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.ID, nil
|
||||
}
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Apply ownership change (admin only)
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("REST Adapter", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("NewRepository", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
It("sets the owner from the context user", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "New Playlist"}
|
||||
id, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(pls.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("forces a new creation by clearing ID", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("denies regular user from changing ownership", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("delegates to service Delete with permission checks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package playlists
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("basic", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
Context("cross-library", func() {
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("With absolute paths", func() {
|
||||
It("resolves path within a library", func() {
|
||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package playlists_test
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -19,18 +19,18 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists - Import", func() {
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
var ps core.Playlists
|
||||
var mockPlsRepo mockedPlaylistRepo
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
mockPlsRepo = mockedPlaylistRepo{}
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedPlaylist: &mockPlsRepo,
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// Set up library with the actual library path that matches the folder
|
||||
@@ -61,7 +61,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
@@ -99,7 +99,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||
@@ -149,7 +149,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
|
||||
// Create the playlist file on disk with the filesystem's normalization form
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
@@ -163,7 +163,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Path: storedPath,
|
||||
Sync: true,
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
|
||||
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
|
||||
|
||||
// Import using the filesystem's normalization form
|
||||
plsFolder := &model.Folder{
|
||||
@@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||
},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("handles relative paths that reference files in other libraries", func() {
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
},
|
||||
}
|
||||
// Recreate playlists service to pick up new mock
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
|
||||
// Create playlist in music library that references both tracks
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
@@ -408,7 +408,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ps = core.NewPlaylists(ds)
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -439,7 +439,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
||||
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
@@ -460,7 +460,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
|
||||
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||
repo.data = []string{
|
||||
"album1/test1.mp3",
|
||||
"album2/test2.mp3",
|
||||
@@ -570,7 +570,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("InPath", func() {
|
||||
Describe("InPlaylistsPath", func() {
|
||||
var folder model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -584,27 +584,27 @@ var _ = Describe("Playlists - Import", func() {
|
||||
|
||||
It("returns true if PlaylistsPath is empty", func() {
|
||||
conf.Server.PlaylistsPath = ""
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||
conf.Server.PlaylistsPath = "**/**"
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false if folder is not in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other"
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||
conf.Server.PlaylistsPath = "."
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
|
||||
folder2 := model.Folder{
|
||||
LibraryPath: "/music",
|
||||
@@ -612,7 +612,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Name: ".",
|
||||
}
|
||||
|
||||
Expect(playlists.InPath(folder2)).To(BeTrue())
|
||||
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -693,3 +693,23 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylistRepo struct {
|
||||
last *model.Playlist
|
||||
data map[string]*model.Playlist // keyed by path
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
if r.data != nil {
|
||||
if pls, ok := r.data[path]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
r.last = pls
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
)
|
||||
|
||||
@@ -17,7 +16,7 @@ var Set = wire.NewSet(
|
||||
NewArchiver,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
playlists.NewPlaylists,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
|
||||
2
go.mod
2
go.mod
@@ -7,7 +7,7 @@ replace (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
4
go.sum
4
go.sum
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
|
||||
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
|
||||
@@ -96,6 +96,16 @@ func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
pls, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pls.OwnerID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
@@ -103,6 +113,14 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
pls := dbPlaylist{Playlist: *p}
|
||||
if pls.ID == "" {
|
||||
pls.CreatedAt = time.Now()
|
||||
} else {
|
||||
ok, err := r.Exists(pls.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
}
|
||||
pls.UpdatedAt = time.Now()
|
||||
|
||||
@@ -114,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
|
||||
if p.IsSmartPlaylist() {
|
||||
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
|
||||
//r.refreshSmartPlaylist(p)
|
||||
return nil
|
||||
}
|
||||
// Only update tracks if they were specified
|
||||
@@ -301,6 +320,10 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er
|
||||
}
|
||||
|
||||
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
|
||||
if !r.isWritable(playlistId) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Remove old tracks
|
||||
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
|
||||
_, err := r.executeSQL(del)
|
||||
@@ -416,7 +439,8 @@ func (r *playlistRepository) NewInstance() any {
|
||||
|
||||
func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
pls := entity.(*model.Playlist)
|
||||
pls.ID = "" // Force new creation
|
||||
pls.OwnerID = loggedUser(r.ctx).ID
|
||||
pls.ID = "" // Make sure we don't override an existing playlist
|
||||
err := r.Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -426,9 +450,24 @@ func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
|
||||
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
||||
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
||||
current, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
usr := loggedUser(r.ctx)
|
||||
if !usr.IsAdmin {
|
||||
// Only the owner can update the playlist
|
||||
if current.OwnerID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Regular users can't change the ownership of a playlist
|
||||
if pls.OwnerID != "" && pls.OwnerID != usr.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
pls.ID = id
|
||||
pls.UpdatedAt = time.Now()
|
||||
_, err := r.put(id, pls, append(cols, "updatedAt")...)
|
||||
_, err = r.put(id, pls, append(cols, "updatedAt")...)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
@@ -468,31 +507,23 @@ func (r *playlistRepository) removeOrphans() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
|
||||
// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
|
||||
// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
|
||||
func (r *playlistRepository) renumber(id string) error {
|
||||
// Step 1: Negate all IDs to clear the positive ID space
|
||||
_, err := r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
|
||||
var ids []string
|
||||
sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
|
||||
err := r.queryAllSlice(sq, &ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
|
||||
// The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
|
||||
// ORDER BY id DESC restores original order since IDs are now negative.
|
||||
_, err = r.executeSQL(Expr(
|
||||
`WITH new_ids AS (
|
||||
SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
|
||||
FROM playlist_tracks WHERE playlist_id = ?
|
||||
)
|
||||
UPDATE playlist_tracks SET id = new_ids.new_id
|
||||
FROM new_ids
|
||||
WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
|
||||
if err != nil {
|
||||
return err
|
||||
return r.updatePlaylist(id, ids)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) isWritable(playlistId string) bool {
|
||||
usr := loggedUser(r.ctx)
|
||||
if usr.IsAdmin {
|
||||
return true
|
||||
}
|
||||
return r.refreshCounters(&model.Playlist{ID: id})
|
||||
pls, err := r.Get(playlistId)
|
||||
return err == nil && pls.OwnerID == usr.ID
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*playlistRepository)(nil)
|
||||
|
||||
@@ -401,79 +401,6 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Track Deletion and Renumbering", func() {
|
||||
var testPlaylistID string
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
Expect(repo.Delete(testPlaylistID)).To(BeNil())
|
||||
testPlaylistID = ""
|
||||
}
|
||||
})
|
||||
|
||||
// helper to get track positions and media file IDs
|
||||
getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) {
|
||||
pls, err := repo.GetWithTracks(playlistID, false, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, t := range pls.Tracks {
|
||||
ids = append(ids, t.ID)
|
||||
mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
It("renumbers correctly after deleting a track from the middle", func() {
|
||||
By("creating a playlist with 4 tracks")
|
||||
newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"}
|
||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("deleting the second track (position 2)")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
Expect(tracksRepo.Delete("2")).To(Succeed())
|
||||
|
||||
By("verifying remaining tracks are renumbered sequentially")
|
||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
||||
Expect(ids).To(Equal([]string{"1", "2", "3"}))
|
||||
Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
|
||||
})
|
||||
|
||||
It("renumbers correctly after deleting the first track", func() {
|
||||
By("creating a playlist with 3 tracks")
|
||||
newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"}
|
||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("deleting the first track (position 1)")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
Expect(tracksRepo.Delete("1")).To(Succeed())
|
||||
|
||||
By("verifying remaining tracks are renumbered sequentially")
|
||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
||||
Expect(ids).To(Equal([]string{"1", "2"}))
|
||||
Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
|
||||
})
|
||||
|
||||
It("renumbers correctly after deleting the last track", func() {
|
||||
By("creating a playlist with 3 tracks")
|
||||
newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"}
|
||||
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("deleting the last track (position 3)")
|
||||
tracksRepo := repo.Tracks(newPls.ID, false)
|
||||
Expect(tracksRepo.Delete("3")).To(Succeed())
|
||||
|
||||
By("verifying remaining tracks are renumbered sequentially")
|
||||
ids, mediaFileIDs := getTrackInfo(newPls.ID)
|
||||
Expect(ids).To(Equal([]string{"1", "2"}))
|
||||
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlists Library Filtering", func() {
|
||||
var mfRepo model.MediaFileRepository
|
||||
var testPlaylistID string
|
||||
|
||||
@@ -140,7 +140,15 @@ func (r *playlistTrackRepository) NewInstance() any {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) isTracksEditable() bool {
|
||||
return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
||||
if !r.isTracksEditable() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if len(mediaFileIds) > 0 {
|
||||
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
|
||||
} else {
|
||||
@@ -188,7 +196,22 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
|
||||
return r.addMediaFileIds(clauses)
|
||||
}
|
||||
|
||||
// Get ids from all current tracks
|
||||
func (r *playlistTrackRepository) getTracks() ([]string, error) {
|
||||
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
|
||||
var ids []string
|
||||
err := r.queryAllSlice(all, &ids)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Delete(ids ...string) error {
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -198,6 +221,9 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) DeleteAll() error {
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"playlist_id": r.playlistId})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -206,45 +232,16 @@ func (r *playlistTrackRepository) DeleteAll() error {
|
||||
return r.playlistRepo.renumber(r.playlistId)
|
||||
}
|
||||
|
||||
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
|
||||
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
|
||||
if pos == newPos {
|
||||
return nil
|
||||
if !r.isTracksEditable() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
pid := r.playlistId
|
||||
|
||||
// Step 1: Move the source track out of the way (temporary sentinel value)
|
||||
_, err := r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
|
||||
ids, err := r.getTracks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
|
||||
if pos < newPos {
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
|
||||
pid, pos, newPos))
|
||||
} else {
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
|
||||
pid, newPos, pos))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Flip the shifted range back to positive
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Place the source track at its new position
|
||||
_, err = r.executeSQL(Expr(
|
||||
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
|
||||
return err
|
||||
newOrder := slice.Move(ids, pos-1, newPos-1)
|
||||
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
|
||||
}
|
||||
|
||||
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)
|
||||
|
||||
52
plugins/capabilities/http_endpoint.go
Normal file
52
plugins/capabilities/http_endpoint.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package capabilities
|
||||
|
||||
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||
//
|
||||
//nd:capability name=httpendpoint required=true
|
||||
type HTTPEndpoint interface {
|
||||
// HandleRequest processes an incoming HTTP request and returns a response.
|
||||
//nd:export name=nd_http_handle_request raw=true
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
}
|
||||
|
||||
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
type HTTPHandleRequest struct {
|
||||
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
Method string `json:"method"`
|
||||
// Path is the request path relative to the plugin's base URL.
|
||||
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
Path string `json:"path"`
|
||||
// Query is the raw query string without the leading '?'.
|
||||
Query string `json:"query,omitempty"`
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body []byte `json:"body,omitempty"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPUser contains authenticated user information passed to the plugin.
|
||||
type HTTPUser struct {
|
||||
// ID is the internal Navidrome user ID.
|
||||
ID string `json:"id"`
|
||||
// Username is the user's login name.
|
||||
Username string `json:"username"`
|
||||
// Name is the user's display name.
|
||||
Name string `json:"name"`
|
||||
// IsAdmin indicates whether the user has admin privileges.
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
type HTTPHandleResponse struct {
|
||||
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
Status int32 `json:"status,omitempty"`
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
81
plugins/capabilities/http_endpoint.yaml
Normal file
81
plugins/capabilities/http_endpoint.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
version: v1-draft
|
||||
exports:
|
||||
nd_http_handle_request:
|
||||
description: HandleRequest processes an incoming HTTP request and returns a response.
|
||||
input:
|
||||
$ref: '#/components/schemas/HTTPHandleRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/HTTPHandleResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
HTTPHandleRequest:
|
||||
description: HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
description: Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
path:
|
||||
type: string
|
||||
description: |-
|
||||
Path is the request path relative to the plugin's base URL.
|
||||
For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
query:
|
||||
type: string
|
||||
description: Query is the raw query string without the leading '?'.
|
||||
headers:
|
||||
type: object
|
||||
description: Headers contains the HTTP request headers.
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
body:
|
||||
type: buffer
|
||||
description: Body is the request body content.
|
||||
user:
|
||||
$ref: '#/components/schemas/HTTPUser'
|
||||
description: User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
nullable: true
|
||||
required:
|
||||
- method
|
||||
- path
|
||||
HTTPHandleResponse:
|
||||
description: HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
properties:
|
||||
status:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
headers:
|
||||
type: object
|
||||
description: Headers contains the HTTP response headers to set.
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
body:
|
||||
type: buffer
|
||||
description: Body is the response body content.
|
||||
HTTPUser:
|
||||
description: HTTPUser contains authenticated user information passed to the plugin.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome user ID.
|
||||
username:
|
||||
type: string
|
||||
description: Username is the user's login name.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the user's display name.
|
||||
isAdmin:
|
||||
type: boolean
|
||||
description: IsAdmin indicates whether the user has admin privileges.
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- name
|
||||
- isAdmin
|
||||
14
plugins/capability_http_endpoint.go
Normal file
14
plugins/capability_http_endpoint.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package plugins
|
||||
|
||||
// CapabilityHTTPEndpoint indicates the plugin can handle incoming HTTP requests.
|
||||
// Detected when the plugin exports the nd_http_handle_request function.
|
||||
const CapabilityHTTPEndpoint Capability = "HTTPEndpoint"
|
||||
|
||||
const FuncHTTPHandleRequest = "nd_http_handle_request"
|
||||
|
||||
func init() {
|
||||
registerCapability(
|
||||
CapabilityHTTPEndpoint,
|
||||
FuncHTTPHandleRequest,
|
||||
)
|
||||
}
|
||||
@@ -364,6 +364,27 @@ func capabilityFuncMap(cap Capability) template.FuncMap {
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"implVar": func(e Export) string { return e.ImplVarName() },
|
||||
"exportFunc": func(e Export) string { return e.ExportFuncName() },
|
||||
"rawFieldName": rawFieldName(cap),
|
||||
}
|
||||
}
|
||||
|
||||
// rawFieldName returns a template function that finds the first []byte field name
|
||||
// in a struct by type name. This is used by raw export templates to generate
|
||||
// field-specific binary frame code.
|
||||
func rawFieldName(cap Capability) func(string) string {
|
||||
structMap := make(map[string]StructDef)
|
||||
for _, s := range cap.Structs {
|
||||
structMap[s.Name] = s
|
||||
}
|
||||
return func(typeName string) string {
|
||||
if s, ok := structMap[typeName]; ok {
|
||||
for _, f := range s.Fields {
|
||||
if f.Type == "[]byte" {
|
||||
return f.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +487,7 @@ func rustCapabilityFuncMap(cap Capability) template.FuncMap {
|
||||
"providerInterface": func(e Export) string { return e.ProviderInterfaceName() },
|
||||
"registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) },
|
||||
"snakeCase": ToSnakeCase,
|
||||
"rawFieldName": rawFieldName(cap),
|
||||
"indent": func(spaces int, s string) string {
|
||||
indent := strings.Repeat(" ", spaces)
|
||||
lines := strings.Split(s, "\n")
|
||||
@@ -560,9 +582,15 @@ func rustConstName(name string) string {
|
||||
|
||||
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
|
||||
func skipSerializingFunc(goType string) string {
|
||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
|
||||
if goType == "[]byte" {
|
||||
return "Vec::is_empty"
|
||||
}
|
||||
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") {
|
||||
return "Option::is_none"
|
||||
}
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
return "HashMap::is_empty"
|
||||
}
|
||||
switch goType {
|
||||
case "string":
|
||||
return "String::is_empty"
|
||||
|
||||
@@ -1432,12 +1432,20 @@ type OnInitOutput struct {
|
||||
|
||||
var _ = Describe("Rust Generation", func() {
|
||||
Describe("skipSerializingFunc", func() {
|
||||
It("should return Option::is_none for pointer, slice, and map types", func() {
|
||||
It("should return Vec::is_empty for []byte type", func() {
|
||||
Expect(skipSerializingFunc("[]byte")).To(Equal("Vec::is_empty"))
|
||||
})
|
||||
|
||||
It("should return Option::is_none for pointer and slice types", func() {
|
||||
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
|
||||
})
|
||||
|
||||
It("should return HashMap::is_empty for map types", func() {
|
||||
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
|
||||
Expect(skipSerializingFunc("map[string]string")).To(Equal("HashMap::is_empty"))
|
||||
})
|
||||
|
||||
It("should return String::is_empty for string type", func() {
|
||||
|
||||
@@ -269,6 +269,7 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Doc: doc,
|
||||
Raw: annotation["raw"] == "true",
|
||||
}
|
||||
|
||||
// Capability exports have exactly one input parameter (the struct type)
|
||||
|
||||
@@ -635,6 +635,68 @@ type Output struct {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseCapabilities raw=true", func() {
|
||||
It("should parse raw=true export annotation", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=httpendpoint required=true
|
||||
type HTTPEndpoint interface {
|
||||
//nd:export name=nd_http_handle_request raw=true
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
}
|
||||
|
||||
type HTTPHandleRequest struct {
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||
}
|
||||
|
||||
type HTTPHandleResponse struct {
|
||||
Status int32 ` + "`json:\"status,omitempty\"`" + `
|
||||
Body []byte ` + "`json:\"body,omitempty\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "http_endpoint.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
cap := capabilities[0]
|
||||
Expect(cap.Methods).To(HaveLen(1))
|
||||
Expect(cap.Methods[0].Raw).To(BeTrue())
|
||||
Expect(cap.HasRawMethods()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should default Raw to false for export annotations without raw", func() {
|
||||
src := `package capabilities
|
||||
|
||||
//nd:capability name=test required=true
|
||||
type TestCapability interface {
|
||||
//nd:export name=nd_test
|
||||
Test(TestInput) (TestOutput, error)
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
Value string ` + "`json:\"value\"`" + `
|
||||
}
|
||||
|
||||
type TestOutput struct {
|
||||
Result string ` + "`json:\"result\"`" + `
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
capabilities, err := ParseCapabilities(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(capabilities).To(HaveLen(1))
|
||||
|
||||
Expect(capabilities[0].Methods[0].Raw).To(BeFalse())
|
||||
Expect(capabilities[0].HasRawMethods()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Export helpers", func() {
|
||||
It("should generate correct provider interface name", func() {
|
||||
e := Export{Name: "GetArtistBiography"}
|
||||
|
||||
@@ -9,6 +9,10 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
{{- if .Capability.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
{{- end}}
|
||||
)
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
@@ -56,6 +60,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@@ -68,8 +73,12 @@ type {{.Name}} struct {
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
{{.Name}} {{.Type}} `json:"-"`
|
||||
{{- else}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -172,6 +181,53 @@ func {{exportFunc .}}() int32 {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
{{- if .Raw}}
|
||||
{{- /* Raw binary frame input/output */ -}}
|
||||
{{- if .HasInput}}
|
||||
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
raw := pdk.Input()
|
||||
if len(raw) < 4 {
|
||||
pdk.SetErrorString("malformed input frame")
|
||||
return -1
|
||||
}
|
||||
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||
if uint32(len(raw)-4) < jsonLen {
|
||||
pdk.SetErrorString("invalid json length in input frame")
|
||||
return -1
|
||||
}
|
||||
var input {{.Input.Type}}
|
||||
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
input.{{rawFieldName .Input.Type}} = raw[4+jsonLen:]
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
|
||||
output, err := {{implVar .}}(input)
|
||||
if err != nil {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
errMsg := []byte(err.Error())
|
||||
errFrame := make([]byte, 1+len(errMsg))
|
||||
errFrame[0] = 0x01
|
||||
copy(errFrame[1:], errMsg)
|
||||
pdk.Output(errFrame)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
jsonBytes, _ := json.Marshal(output)
|
||||
rawBytes := output.{{rawFieldName .Output.Type}}
|
||||
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||
frame[0] = 0x00
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||
pdk.Output(frame)
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
{{- /* Standard JSON input/output */ -}}
|
||||
{{- if .HasInput}}
|
||||
|
||||
var input {{.Input.Type}}
|
||||
@@ -216,6 +272,7 @@ func {{exportFunc .}}() int32 {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -52,6 +52,7 @@ pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}};
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@@ -66,13 +67,17 @@ pub struct {{.Name}} {
|
||||
{{- if .Doc}}
|
||||
{{rustDocComment .Doc | indent 4}}
|
||||
{{- end}}
|
||||
{{- if .OmitEmpty}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
#[serde(skip)]
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- else if .OmitEmpty}}
|
||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- else}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -124,6 +129,56 @@ pub trait {{agentName .Capability}} {
|
||||
macro_rules! register_{{snakeCase .Package}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- range .Capability.Methods}}
|
||||
{{- if .Raw}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if .HasInput}}
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
match $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
@@ -146,6 +201,7 @@ macro_rules! register_{{snakeCase .Package}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- else}}
|
||||
@@ -171,6 +227,56 @@ pub trait {{providerInterface .}} {
|
||||
#[macro_export]
|
||||
macro_rules! {{registerMacroName .Name}} {
|
||||
($plugin_type:ty) => {
|
||||
{{- if .Raw}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
{{- end}}
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
{{- if .HasInput}}
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}} = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.{{rustFieldName (rawFieldName .Input.Type)}} = raw_bytes[4+json_len..].to_vec();
|
||||
{{- end}}
|
||||
{{- if and .HasInput .HasOutput}}
|
||||
match $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.{{rustFieldName (rawFieldName .Output.Type)}};
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else}}
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn {{.ExportName}}(
|
||||
{{- if .HasInput}}
|
||||
@@ -192,6 +298,7 @@ macro_rules! {{registerMacroName .Name}} {
|
||||
Ok(())
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
};
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -53,6 +53,7 @@ func (e {{$typeName}}) Error() string { return string(e) }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- $capability := .Capability}}
|
||||
{{- range .Capability.Structs}}
|
||||
|
||||
{{- if .Doc}}
|
||||
@@ -65,8 +66,12 @@ type {{.Name}} struct {
|
||||
{{- if .Doc}}
|
||||
{{formatDoc .Doc | indent 1}}
|
||||
{{- end}}
|
||||
{{- if and (eq .Type "[]byte") $capability.HasRawMethods}}
|
||||
{{.Name}} {{.Type}} `json:"-"`
|
||||
{{- else}}
|
||||
{{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"`
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
|
||||
@@ -48,6 +48,16 @@ type ConstDef struct {
|
||||
Doc string // Documentation comment
|
||||
}
|
||||
|
||||
// HasRawMethods returns true if any export in the capability uses raw binary framing.
|
||||
func (c Capability) HasRawMethods() bool {
|
||||
for _, m := range c.Methods {
|
||||
if m.Raw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KnownStructs returns a map of struct names defined in this capability.
|
||||
func (c Capability) KnownStructs() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
@@ -64,6 +74,7 @@ type Export struct {
|
||||
Input Param // Single input parameter (the struct type)
|
||||
Output Param // Single output return value (the struct type)
|
||||
Doc string // Documentation comment for the method
|
||||
Raw bool // If true, uses binary framing instead of JSON for []byte fields
|
||||
}
|
||||
|
||||
// ProviderInterfaceName returns the optional provider interface name.
|
||||
|
||||
@@ -54,6 +54,14 @@ type (
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
Items *xtpProperty `yaml:"items,omitempty"`
|
||||
}
|
||||
|
||||
// xtpMapProperty represents a map property in XTP (type: object with additionalProperties).
|
||||
xtpMapProperty struct {
|
||||
Type string `yaml:"type"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Nullable bool `yaml:"nullable,omitempty"`
|
||||
AdditionalProperties *xtpProperty `yaml:"additionalProperties"`
|
||||
}
|
||||
)
|
||||
|
||||
// GenerateSchema generates an XTP YAML schema from a capability.
|
||||
@@ -206,7 +214,12 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema
|
||||
|
||||
for _, field := range st.Fields {
|
||||
propName := getJSONFieldName(field)
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
goType := strings.TrimPrefix(field.Type, "*")
|
||||
if strings.HasPrefix(goType, "map[") {
|
||||
addToMap(&schema.Properties, propName, buildMapProperty(goType, field.Doc, strings.HasPrefix(field.Type, "*"), knownTypes))
|
||||
} else {
|
||||
addToMap(&schema.Properties, propName, buildProperty(field, knownTypes))
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty {
|
||||
schema.Required = append(schema.Required, propName)
|
||||
@@ -246,6 +259,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle []byte as buffer type (must be checked before generic slice handling)
|
||||
if goType == "[]byte" {
|
||||
prop.Type = "buffer"
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
elemType := goType[2:]
|
||||
@@ -264,6 +283,55 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// buildMapProperty builds an XTP MapProperty for a Go map type.
|
||||
// It parses map[K]V and generates additionalProperties describing V.
|
||||
func buildMapProperty(goType, doc string, isPointer bool, knownTypes map[string]bool) xtpMapProperty {
|
||||
prop := xtpMapProperty{
|
||||
Type: "object",
|
||||
Description: cleanDocForYAML(doc),
|
||||
Nullable: isPointer,
|
||||
}
|
||||
|
||||
// Parse value type from map[K]V
|
||||
valueType := parseMapValueType(goType)
|
||||
|
||||
valProp := &xtpProperty{}
|
||||
if strings.HasPrefix(valueType, "[]") {
|
||||
elemType := valueType[2:]
|
||||
valProp.Type = "array"
|
||||
valProp.Items = &xtpProperty{}
|
||||
if isKnownType(elemType, knownTypes) {
|
||||
valProp.Items.Ref = "#/components/schemas/" + elemType
|
||||
} else {
|
||||
valProp.Items.Type = goTypeToXTPType(elemType)
|
||||
}
|
||||
} else if isKnownType(valueType, knownTypes) {
|
||||
valProp.Ref = "#/components/schemas/" + valueType
|
||||
} else {
|
||||
valProp.Type, valProp.Format = goTypeToXTPTypeAndFormat(valueType)
|
||||
}
|
||||
prop.AdditionalProperties = valProp
|
||||
|
||||
return prop
|
||||
}
|
||||
|
||||
// parseMapValueType extracts the value type from a Go map type string like "map[string][]string".
|
||||
func parseMapValueType(goType string) string {
|
||||
// Find the closing bracket of the key type
|
||||
depth := 0
|
||||
for i, ch := range goType {
|
||||
if ch == '[' {
|
||||
depth++
|
||||
} else if ch == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return goType[i+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return "object" // fallback
|
||||
}
|
||||
|
||||
// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order.
|
||||
func addToMap[T any](node *yaml.Node, key string, value T) {
|
||||
var valNode yaml.Node
|
||||
|
||||
@@ -719,4 +719,139 @@ var _ = Describe("XTP Schema Generation", func() {
|
||||
Expect(schemas).NotTo(HaveKey("UnusedStatus"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with []byte fields", func() {
|
||||
It("should render []byte as buffer type and validate against XTP JSONSchema", func() {
|
||||
capability := Capability{
|
||||
Name: "buffer_test",
|
||||
SourceFile: "buffer_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Name", Type: "string", JSONTag: "name"},
|
||||
{Name: "Data", Type: "[]byte", JSONTag: "data,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Body", Type: "[]byte", JSONTag: "body,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
data := props["data"].(map[string]any)
|
||||
Expect(data["type"]).To(Equal("buffer"))
|
||||
Expect(data).NotTo(HaveKey("items"))
|
||||
Expect(data).NotTo(HaveKey("format"))
|
||||
|
||||
output := schemas["Output"].(map[string]any)
|
||||
outProps := output["properties"].(map[string]any)
|
||||
body := outProps["body"].(map[string]any)
|
||||
Expect(body["type"]).To(Equal("buffer"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateSchema with map fields", func() {
|
||||
It("should render map[string][]string as object with additionalProperties and validate", func() {
|
||||
capability := Capability{
|
||||
Name: "map_test",
|
||||
SourceFile: "map_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Headers", Type: "map[string][]string", JSONTag: "headers,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
headers := props["headers"].(map[string]any)
|
||||
Expect(headers).To(HaveKey("additionalProperties"))
|
||||
addlProps := headers["additionalProperties"].(map[string]any)
|
||||
Expect(addlProps["type"]).To(Equal("array"))
|
||||
items := addlProps["items"].(map[string]any)
|
||||
Expect(items["type"]).To(Equal("string"))
|
||||
})
|
||||
|
||||
It("should render map[string]string as object with string additionalProperties", func() {
|
||||
capability := Capability{
|
||||
Name: "map_string_test",
|
||||
SourceFile: "map_string_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Metadata", Type: "map[string]string", JSONTag: "metadata,omitempty", OmitEmpty: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
metadata := props["metadata"].(map[string]any)
|
||||
Expect(metadata).To(HaveKey("additionalProperties"))
|
||||
addlProps := metadata["additionalProperties"].(map[string]any)
|
||||
Expect(addlProps["type"]).To(Equal("string"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseMapValueType", func() {
|
||||
DescribeTable("should extract value type from Go map types",
|
||||
func(goType, wantValue string) {
|
||||
Expect(parseMapValueType(goType)).To(Equal(wantValue))
|
||||
},
|
||||
Entry("map[string]string", "map[string]string", "string"),
|
||||
Entry("map[string]int", "map[string]int", "int"),
|
||||
Entry("map[string][]string", "map[string][]string", "[]string"),
|
||||
Entry("map[string][]byte", "map[string][]byte", "[]byte"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,27 +26,19 @@ const subsonicAPIVersion = "1.16.1"
|
||||
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
|
||||
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
userIDMap map[string]struct{}
|
||||
pluginName string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
userAccess UserAccess
|
||||
}
|
||||
|
||||
// newSubsonicAPIService creates a new SubsonicAPIService for a plugin.
|
||||
func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, allowedUserIDs []string, allUsers bool) host.SubsonicAPIService {
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
func newSubsonicAPIService(pluginName string, router SubsonicRouter, ds model.DataStore, userAccess UserAccess) host.SubsonicAPIService {
|
||||
return &subsonicAPIServiceImpl{
|
||||
pluginID: pluginID,
|
||||
router: router,
|
||||
ds: ds,
|
||||
allowedUserIDs: allowedUserIDs,
|
||||
allUsers: allUsers,
|
||||
userIDMap: userIDMap,
|
||||
pluginName: pluginName,
|
||||
router: router,
|
||||
ds: ds,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +66,12 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
||||
}
|
||||
|
||||
if err := s.checkPermissions(ctx, username); err != nil {
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginName, "user", username, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("c", s.pluginName) // Client name (plugin ID)
|
||||
query.Set("v", subsonicAPIVersion) // API version
|
||||
if setJSON {
|
||||
query.Set("f", "json") // Response format
|
||||
@@ -94,11 +86,8 @@ func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string,
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
|
||||
// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
|
||||
// SubsonicAPI call doesn't inherit routing information from the parent handler,
|
||||
// which would cause Chi to invoke the wrong handler. Authentication context is
|
||||
// explicitly added in the next step via request.WithInternalAuth.
|
||||
// Use http.NewRequest (not WithContext) to avoid inheriting Chi RouteContext;
|
||||
// auth context is set explicitly below via request.WithInternalAuth.
|
||||
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
@@ -135,14 +124,13 @@ func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (strin
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||
// If allUsers is true, allow any user
|
||||
if s.allUsers {
|
||||
if s.userAccess.allUsers {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must have at least one allowed user ID configured
|
||||
if len(s.allowedUserIDs) == 0 {
|
||||
return fmt.Errorf("no users configured for plugin %s", s.pluginID)
|
||||
// Must have at least one allowed user configured
|
||||
if !s.userAccess.HasConfiguredUsers() {
|
||||
return fmt.Errorf("no users configured for plugin %s", s.pluginName)
|
||||
}
|
||||
|
||||
// Look up the user by username to get their ID
|
||||
@@ -155,7 +143,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username
|
||||
}
|
||||
|
||||
// Check if the user's ID is in the allowed list
|
||||
if _, ok := s.userIDMap[usr.ID]; !ok {
|
||||
if !s.userAccess.IsAllowed(usr.ID) {
|
||||
return fmt.Errorf("user %s is not authorized for this plugin", username)
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
Context("with specific user IDs allowed", func() {
|
||||
It("blocks users not in the allowed list", func() {
|
||||
// allowedUserIDs contains "user2", but testuser is "user1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -278,7 +278,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("allows users in the allowed list", func() {
|
||||
// allowedUserIDs contains "user2" which is "alloweduser"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=alloweduser")
|
||||
@@ -288,7 +288,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("blocks admin users when not in allowed list", func() {
|
||||
// allowedUserIDs only contains "user1" (testuser), not "admin1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@@ -298,7 +298,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
It("allows admin users when in allowed list", func() {
|
||||
// allowedUserIDs contains "admin1"
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"admin1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@@ -309,7 +309,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
It("allows all users regardless of allowed list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -318,7 +318,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("allows admin users when allUsers is true", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
response, err := service.Call(ctx, "/ping?u=adminuser")
|
||||
@@ -329,7 +329,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Context("with no users configured", func() {
|
||||
It("returns error when no users are configured", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -338,7 +338,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for empty user list", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
@@ -350,7 +350,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("URL Handling", func() {
|
||||
It("returns error for missing username parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping")
|
||||
@@ -359,7 +359,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "://invalid")
|
||||
@@ -368,7 +368,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("extracts endpoint from path correctly", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user1"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/rest/ping.view?u=testuser")
|
||||
@@ -381,7 +381,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("CallRaw", func() {
|
||||
It("returns binary data and content-type", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@@ -391,7 +391,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("does not set f=json parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@@ -403,7 +403,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("enforces permission checks", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(false, []string{"user2"}))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
@@ -412,7 +412,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error when username is missing", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
||||
@@ -421,7 +421,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
||||
@@ -430,7 +430,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "://invalid")
|
||||
@@ -441,7 +441,7 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
|
||||
Describe("Router Availability", func() {
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, NewUserAccess(true, nil))
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, err := service.Call(ctx, "/ping?u=testuser")
|
||||
|
||||
@@ -9,16 +9,14 @@ import (
|
||||
)
|
||||
|
||||
type usersServiceImpl struct {
|
||||
ds model.DataStore
|
||||
allowedUsers []string // User IDs this plugin can access
|
||||
allUsers bool // If true, plugin can access all users
|
||||
ds model.DataStore
|
||||
userAccess UserAccess
|
||||
}
|
||||
|
||||
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
|
||||
func newUsersService(ds model.DataStore, userAccess UserAccess) host.UsersService {
|
||||
return &usersServiceImpl{
|
||||
ds: ds,
|
||||
allowedUsers: allowedUsers,
|
||||
allUsers: allUsers,
|
||||
ds: ds,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +26,9 @@ func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build allowed users map for efficient lookup
|
||||
allowedMap := make(map[string]bool, len(s.allowedUsers))
|
||||
for _, id := range s.allowedUsers {
|
||||
allowedMap[id] = true
|
||||
}
|
||||
|
||||
var result []host.User
|
||||
for _, u := range users {
|
||||
// If allUsers is true, include all users
|
||||
// Otherwise, only include users in the allowed list
|
||||
if s.allUsers || allowedMap[u.ID] {
|
||||
if s.userAccess.IsAllowed(u.ID) {
|
||||
result = append(result, host.User{
|
||||
UserName: u.UserName,
|
||||
Name: u.Name,
|
||||
|
||||
@@ -61,7 +61,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should return all users", func() {
|
||||
@@ -100,7 +100,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users", func() {
|
||||
BeforeEach(func() {
|
||||
// Only allow access to user1 and user3
|
||||
service = newUsersService(ds, []string{"user1", "user3"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user3"}))
|
||||
})
|
||||
|
||||
It("should return only allowed users", func() {
|
||||
@@ -119,7 +119,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with empty allowed users and allUsers=false", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, []string{}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{}))
|
||||
})
|
||||
|
||||
It("should return no users", func() {
|
||||
@@ -132,7 +132,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("when datastore returns error", func() {
|
||||
BeforeEach(func() {
|
||||
mockUserRepo.Error = model.ErrNotFound
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should propagate the error", func() {
|
||||
@@ -170,7 +170,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
|
||||
Context("with allUsers=true", func() {
|
||||
BeforeEach(func() {
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should return only admin users", func() {
|
||||
@@ -185,7 +185,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users including admin", func() {
|
||||
BeforeEach(func() {
|
||||
// Allow access to user1 (admin) and user2 (non-admin)
|
||||
service = newUsersService(ds, []string{"user1", "user2"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user1", "user2"}))
|
||||
})
|
||||
|
||||
It("should return only admin users from allowed list", func() {
|
||||
@@ -199,7 +199,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("with specific allowed users excluding admin", func() {
|
||||
BeforeEach(func() {
|
||||
// Only allow access to non-admin users
|
||||
service = newUsersService(ds, []string{"user2", "user3"}, false)
|
||||
service = newUsersService(ds, NewUserAccess(false, []string{"user2", "user3"}))
|
||||
})
|
||||
|
||||
It("should return empty when no admins in allowed list", func() {
|
||||
@@ -212,7 +212,7 @@ var _ = Describe("UsersService", Ordered, func() {
|
||||
Context("when datastore returns error", func() {
|
||||
BeforeEach(func() {
|
||||
mockUserRepo.Error = model.ErrNotFound
|
||||
service = newUsersService(ds, nil, true)
|
||||
service = newUsersService(ds, NewUserAccess(true, nil))
|
||||
})
|
||||
|
||||
It("should propagate the error", func() {
|
||||
|
||||
189
plugins/http_endpoint.go
Normal file
189
plugins/http_endpoint.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/capabilities"
|
||||
)
|
||||
|
||||
const maxEndpointBodySize = 1 << 20 // 1MB
|
||||
|
||||
// SubsonicAuthValidator validates Subsonic authentication and returns the user.
|
||||
// This is set by the cmd/ package to avoid import cycles (plugins -> server/subsonic).
|
||||
type SubsonicAuthValidator func(ds model.DataStore, r *http.Request) (*model.User, error)
|
||||
|
||||
// NativeAuthMiddleware is an HTTP middleware that authenticates using JWT tokens.
|
||||
// This is set by the cmd/ package to avoid import cycles (plugins -> server).
|
||||
type NativeAuthMiddleware func(ds model.DataStore) func(next http.Handler) http.Handler
|
||||
|
||||
// NewEndpointRouter creates an HTTP handler that dispatches requests to plugin endpoints.
|
||||
// It should be mounted at both /ext and /rest/ext. The handler uses a catch-all pattern
|
||||
// because Chi does not support adding routes after startup, and plugins can be loaded/unloaded
|
||||
// at runtime. Plugin lookup happens per-request under RLock.
|
||||
func NewEndpointRouter(manager *Manager, ds model.DataStore, subsonicAuth SubsonicAuthValidator, nativeAuth NativeAuthMiddleware) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply rate limiting if configured
|
||||
if conf.Server.Plugins.EndpointRequestLimit > 0 {
|
||||
r.Use(httprate.LimitByIP(conf.Server.Plugins.EndpointRequestLimit, conf.Server.Plugins.EndpointRequestWindow))
|
||||
}
|
||||
|
||||
h := &endpointHandler{
|
||||
manager: manager,
|
||||
ds: ds,
|
||||
subsonicAuth: subsonicAuth,
|
||||
nativeAuth: nativeAuth,
|
||||
}
|
||||
r.HandleFunc("/{pluginID}/*", h.ServeHTTP)
|
||||
r.HandleFunc("/{pluginID}", h.ServeHTTP)
|
||||
return r
|
||||
}
|
||||
|
||||
type endpointHandler struct {
|
||||
manager *Manager
|
||||
ds model.DataStore
|
||||
subsonicAuth SubsonicAuthValidator
|
||||
nativeAuth NativeAuthMiddleware
|
||||
}
|
||||
|
||||
func (h *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
pluginID := chi.URLParam(r, "pluginID")
|
||||
|
||||
h.manager.mu.RLock()
|
||||
p, ok := h.manager.plugins[pluginID]
|
||||
h.manager.mu.RUnlock()
|
||||
|
||||
if !ok || !hasCapability(p.capabilities, CapabilityHTTPEndpoint) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if p.manifest.Permissions == nil || p.manifest.Permissions.Endpoints == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authType := p.manifest.Permissions.Endpoints.Auth
|
||||
|
||||
switch authType {
|
||||
case EndpointsPermissionAuthSubsonic:
|
||||
h.serveWithSubsonicAuth(w, r, p)
|
||||
case EndpointsPermissionAuthNative:
|
||||
h.serveWithNativeAuth(w, r, p)
|
||||
case EndpointsPermissionAuthNone:
|
||||
h.dispatch(w, r, p)
|
||||
default:
|
||||
http.Error(w, "Unknown auth type", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *endpointHandler) serveWithSubsonicAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||
usr, err := h.subsonicAuth(h.ds, r)
|
||||
if err != nil {
|
||||
log.Warn(r.Context(), "Plugin endpoint auth failed", "plugin", p.name, "auth", "subsonic", err)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := request.WithUser(r.Context(), *usr)
|
||||
h.dispatch(w, r.WithContext(ctx), p)
|
||||
}
|
||||
|
||||
func (h *endpointHandler) serveWithNativeAuth(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||
h.nativeAuth(h.ds)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h.dispatch(w, r, p)
|
||||
})).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *plugin) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Check user authorization and extract user info (skip for auth:"none")
|
||||
var httpUser *capabilities.HTTPUser
|
||||
if p.manifest.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone {
|
||||
user, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !p.userAccess.IsAllowed(user.ID) {
|
||||
log.Warn(ctx, "Plugin endpoint access denied", "plugin", p.name, "user", user.UserName)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
httpUser = &capabilities.HTTPUser{
|
||||
ID: user.ID,
|
||||
Username: user.UserName,
|
||||
Name: user.Name,
|
||||
IsAdmin: user.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
// Read request body with size limit
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxEndpointBodySize))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Failed to read request body", "plugin", p.name, err)
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the plugin request
|
||||
// Normalize path: both /ext/plugin and /ext/plugin/ map to ""
|
||||
rawPath := chi.URLParam(r, "*")
|
||||
relPath := ""
|
||||
if rawPath != "" {
|
||||
relPath = "/" + rawPath
|
||||
}
|
||||
|
||||
pluginReq := capabilities.HTTPHandleRequest{
|
||||
Method: r.Method,
|
||||
Path: relPath,
|
||||
Query: r.URL.RawQuery,
|
||||
Headers: r.Header,
|
||||
Body: body,
|
||||
User: httpUser,
|
||||
}
|
||||
|
||||
// Call the plugin using binary framing for []byte Body fields
|
||||
resp, err := callPluginFunctionRaw(
|
||||
ctx, p, FuncHTTPHandleRequest,
|
||||
pluginReq, pluginReq.Body,
|
||||
func(r *capabilities.HTTPHandleResponse, raw []byte) { r.Body = raw },
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Plugin endpoint call failed", "plugin", p.name, "path", relPath, err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Write response headers from plugin
|
||||
for key, values := range resp.Headers {
|
||||
for _, v := range values {
|
||||
w.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Security hardening: override any plugin-set security headers
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
|
||||
|
||||
// Write status code (default to 200)
|
||||
status := int(resp.Status)
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
|
||||
// Write response body
|
||||
if len(resp.Body) > 0 {
|
||||
if _, err := w.Write(resp.Body); err != nil {
|
||||
log.Error(ctx, "Failed to write plugin endpoint response", "plugin", p.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
480
plugins/http_endpoint_test.go
Normal file
480
plugins/http_endpoint_test.go
Normal file
@@ -0,0 +1,480 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// fakeNativeAuth is a mock native auth middleware that authenticates by looking up
|
||||
// the "X-Test-User" header and setting the user in the context.
|
||||
func fakeNativeAuth(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.Header.Get("X-Test-User")
|
||||
if username == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err := ds.User(r.Context()).FindByUsername(username)
|
||||
if err != nil {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := request.WithUser(r.Context(), *user)
|
||||
ctx = request.WithUsername(ctx, user.UserName)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSubsonicAuth is a mock subsonic auth that validates by looking up
|
||||
// the "u" query parameter.
|
||||
func fakeSubsonicAuth(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||
username := r.URL.Query().Get("u")
|
||||
if username == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
user, err := ds.User(r.Context()).FindByUsername(username)
|
||||
if err != nil {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
tmpDir string
|
||||
userRepo *tests.MockedUserRepo
|
||||
dataStore *tests.MockDataStore
|
||||
router http.Handler
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "http-endpoint-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy all test plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||
srcPath := filepath.Join(testdataDir, pluginName+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock data store
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
dataStore = &tests.MockDataStore{MockedUser: userRepo}
|
||||
|
||||
// Add test users
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "user1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
IsAdmin: false,
|
||||
})
|
||||
_ = userRepo.Put(&model.User{
|
||||
ID: "admin1",
|
||||
UserName: "adminuser",
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
})
|
||||
|
||||
// Build enabled plugins list
|
||||
var enabledPlugins model.Plugins
|
||||
for _, pluginName := range []string{"test-http-endpoint", "test-http-endpoint-public", "test-http-endpoint-native"} {
|
||||
pluginPath := filepath.Join(tmpDir, pluginName+PackageExtension)
|
||||
data, err := os.ReadFile(pluginPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
enabledPlugins = append(enabledPlugins, model.Plugin{
|
||||
ID: pluginName,
|
||||
Path: pluginPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
AllUsers: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Setup mock plugin repo
|
||||
mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo)
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(enabledPlugins)
|
||||
|
||||
// Create and start manager
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
metrics: noopMetricsRecorder{},
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create the endpoint router with fake auth functions
|
||||
router = NewEndpointRouter(manager, dataStore, fakeSubsonicAuth, fakeNativeAuth)
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Loading", func() {
|
||||
It("loads the authenticated endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("subsonic"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads the native auth endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint-native"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Native Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("native"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads the public endpoint plugin", func() {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-http-endpoint-public"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
Expect(p).ToNot(BeNil())
|
||||
Expect(p.manifest.Name).To(Equal("Test HTTP Endpoint Public Plugin"))
|
||||
Expect(p.manifest.Permissions.Endpoints).ToNot(BeNil())
|
||||
Expect(string(p.manifest.Permissions.Endpoints.Auth)).To(Equal("none"))
|
||||
Expect(hasCapability(p.capabilities, CapabilityHTTPEndpoint)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Subsonic Auth Endpoints", func() {
|
||||
It("returns hello response with valid auth", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||
})
|
||||
|
||||
It("returns echo response with request details", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser&foo=bar", strings.NewReader("test body"))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["method"]).To(Equal("POST"))
|
||||
Expect(resp["path"]).To(Equal("/echo"))
|
||||
Expect(resp["body"]).To(Equal("test body"))
|
||||
Expect(resp["hasUser"]).To(BeTrue())
|
||||
Expect(resp["username"]).To(Equal("testuser"))
|
||||
})
|
||||
|
||||
It("returns plugin-defined error status", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/error?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
Expect(w.Body.String()).To(Equal("Something went wrong"))
|
||||
})
|
||||
|
||||
It("returns plugin 404 for unknown paths", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/unknown?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(w.Body.String()).To(Equal("Not found: /unknown"))
|
||||
})
|
||||
|
||||
It("returns 401 without auth credentials", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 with invalid auth credentials", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Native Auth Endpoints", func() {
|
||||
It("returns hello response with valid native auth", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
req.Header.Set("X-Test-User", "testuser")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from native auth plugin!"))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("text/plain"))
|
||||
})
|
||||
|
||||
It("returns echo response with user details", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-native/echo?foo=bar", strings.NewReader("native body"))
|
||||
req.Header.Set("X-Test-User", "adminuser")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["method"]).To(Equal("POST"))
|
||||
Expect(resp["path"]).To(Equal("/echo"))
|
||||
Expect(resp["body"]).To(Equal("native body"))
|
||||
Expect(resp["hasUser"]).To(BeTrue())
|
||||
Expect(resp["username"]).To(Equal("adminuser"))
|
||||
})
|
||||
|
||||
It("returns 401 without auth header", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 with invalid auth header", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-native/hello", nil)
|
||||
req.Header.Set("X-Test-User", "nonexistent")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Public Endpoints (auth: none)", func() {
|
||||
It("returns webhook response without auth", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("webhook received"))
|
||||
})
|
||||
|
||||
It("does not pass user info to public endpoints", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-public/check-no-user", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("hasUser=false"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Security Headers", func() {
|
||||
It("includes security headers in authenticated endpoint responses", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||
})
|
||||
|
||||
It("includes security headers in public endpoint responses", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||
})
|
||||
|
||||
It("overrides plugin-set security headers", func() {
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader("body"))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
|
||||
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Unknown Plugin", func() {
|
||||
It("returns 404 for nonexistent plugin", func() {
|
||||
req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("User Authorization", func() {
|
||||
var restrictedRouter http.Handler
|
||||
|
||||
BeforeAll(func() {
|
||||
// Create a manager with a plugin restricted to specific users
|
||||
restrictedTmpDir, err := os.MkdirTemp("", "http-endpoint-restricted-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
srcPath := filepath.Join(testdataDir, "test-http-endpoint"+PackageExtension)
|
||||
destPath := filepath.Join(restrictedTmpDir, "test-http-endpoint"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = restrictedTmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(restrictedTmpDir, "cache")
|
||||
|
||||
restrictedPluginRepo := tests.CreateMockPluginRepo()
|
||||
restrictedPluginRepo.Permitted = true
|
||||
restrictedPluginRepo.SetData(model.Plugins{{
|
||||
ID: "test-http-endpoint",
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
AllUsers: false,
|
||||
Users: `["admin1"]`, // Only admin1 is allowed
|
||||
}})
|
||||
restrictedDS := &tests.MockDataStore{
|
||||
MockedPlugin: restrictedPluginRepo,
|
||||
MockedUser: userRepo,
|
||||
}
|
||||
|
||||
restrictedManager := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: restrictedDS,
|
||||
metrics: noopMetricsRecorder{},
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = restrictedManager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
restrictedRouter = NewEndpointRouter(restrictedManager, restrictedDS, fakeSubsonicAuth, fakeNativeAuth)
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = restrictedManager.Stop()
|
||||
_ = os.RemoveAll(restrictedTmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
It("allows authorized users", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=adminuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
restrictedRouter.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Hello from plugin!"))
|
||||
})
|
||||
|
||||
It("denies unauthorized users", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
restrictedRouter.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Request without trailing path", func() {
|
||||
It("handles requests to plugin root", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint-public/webhook", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Binary Response", func() {
|
||||
It("returns raw binary data intact", func() {
|
||||
req := httptest.NewRequest("GET", "/test-http-endpoint/binary?u=testuser", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
|
||||
// PNG header bytes
|
||||
Expect(w.Body.Bytes()).To(Equal([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Request body handling", func() {
|
||||
It("passes request body to the plugin", func() {
|
||||
body := `{"event":"push","ref":"refs/heads/main"}`
|
||||
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
respBody, err := io.ReadAll(w.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var resp map[string]any
|
||||
err = json.Unmarshal(respBody, &resp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp["body"]).To(Equal(body))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -260,19 +260,10 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Build user ID map for fast lookups
|
||||
userIDMap := make(map[string]struct{})
|
||||
for _, id := range plugin.allowedUserIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Create a new scrobbler adapter for this plugin with user authorization config
|
||||
// Create a new scrobbler adapter for this plugin
|
||||
return &ScrobblerPlugin{
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
allowedUserIDs: plugin.allowedUserIDs,
|
||||
allUsers: plugin.allUsers,
|
||||
userIDMap: userIDMap,
|
||||
name: plugin.name,
|
||||
plugin: plugin,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -60,39 +61,147 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
|
||||
startCall := time.Now()
|
||||
exit, output, err := p.CallWithContext(ctx, funcName, inputBytes)
|
||||
elapsed := time.Since(startCall)
|
||||
|
||||
success := false
|
||||
skipMetrics := false
|
||||
defer func() {
|
||||
if !skipMetrics {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds())
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
// If context was cancelled, return that error instead of the plugin error
|
||||
if ctx.Err() != nil {
|
||||
skipMetrics = true
|
||||
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||
}
|
||||
if exit != 0 {
|
||||
if exit == notImplementedCode {
|
||||
skipMetrics = true
|
||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||
// TODO Should we record metrics for not implemented calls?
|
||||
//plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||
}
|
||||
|
||||
if len(output) > 0 {
|
||||
err = json.Unmarshal(output, &result)
|
||||
if err != nil {
|
||||
if err = json.Unmarshal(output, &result); err != nil {
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics for successful calls (or JSON unmarshal failures)
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds())
|
||||
|
||||
success = true
|
||||
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||
return result, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// callPluginFunctionRaw calls a plugin function using binary framing for []byte fields.
|
||||
// The input is JSON-encoded (with []byte field excluded via json:"-"), followed by raw bytes.
|
||||
// The output frame is: [status:1B][json_len:4B][JSON][raw bytes] for success (0x00),
|
||||
// or [0x01][UTF-8 error message] for errors.
|
||||
func callPluginFunctionRaw[I any, O any](
|
||||
ctx context.Context, plugin *plugin, funcName string,
|
||||
input I, rawInputBytes []byte,
|
||||
setRawOutput func(*O, []byte),
|
||||
) (O, error) {
|
||||
start := time.Now()
|
||||
|
||||
var result O
|
||||
|
||||
p, err := plugin.instance(ctx)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to create plugin: %w", err)
|
||||
}
|
||||
defer p.Close(ctx)
|
||||
|
||||
if !p.FunctionExists(funcName) {
|
||||
log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName)
|
||||
return result, fmt.Errorf("%w: %s", errFunctionNotFound, funcName)
|
||||
}
|
||||
|
||||
// Build input frame: [json_len:4B][JSON][raw bytes]
|
||||
jsonBytes, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
const maxFrameSize = 2 << 20 // 2 MiB
|
||||
if len(jsonBytes) > maxFrameSize || len(rawInputBytes) > maxFrameSize {
|
||||
return result, fmt.Errorf("input frame too large")
|
||||
}
|
||||
frame := make([]byte, 4+len(jsonBytes)+len(rawInputBytes))
|
||||
binary.BigEndian.PutUint32(frame[:4], uint32(len(jsonBytes)))
|
||||
copy(frame[4:4+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[4+len(jsonBytes):], rawInputBytes)
|
||||
|
||||
startCall := time.Now()
|
||||
exit, output, err := p.CallWithContext(ctx, funcName, frame)
|
||||
elapsed := time.Since(startCall)
|
||||
|
||||
success := false
|
||||
skipMetrics := false
|
||||
defer func() {
|
||||
if !skipMetrics {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, success, elapsed.Milliseconds())
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
skipMetrics = true
|
||||
log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err)
|
||||
return result, fmt.Errorf("plugin call failed: %w", err)
|
||||
}
|
||||
if exit != 0 {
|
||||
if exit == notImplementedCode {
|
||||
skipMetrics = true
|
||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||
}
|
||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||
}
|
||||
|
||||
// Parse output frame
|
||||
if len(output) < 1 {
|
||||
return result, fmt.Errorf("empty response from plugin")
|
||||
}
|
||||
|
||||
statusByte := output[0]
|
||||
if statusByte == 0x01 {
|
||||
return result, fmt.Errorf("plugin error: %s", string(output[1:]))
|
||||
}
|
||||
if statusByte != 0x00 {
|
||||
return result, fmt.Errorf("unknown response status byte: 0x%02x", statusByte)
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON][raw bytes]
|
||||
if len(output) < 5 {
|
||||
return result, fmt.Errorf("malformed success response from plugin")
|
||||
}
|
||||
|
||||
jsonLen := binary.BigEndian.Uint32(output[1:5])
|
||||
if uint32(len(output)-5) < jsonLen {
|
||||
return result, fmt.Errorf("invalid json length in response frame: %d exceeds available %d bytes", jsonLen, len(output)-5)
|
||||
}
|
||||
jsonData := output[5 : 5+jsonLen]
|
||||
rawData := output[5+jsonLen:]
|
||||
|
||||
if err := json.Unmarshal(jsonData, &result); err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
setRawOutput(&result, rawData)
|
||||
|
||||
success = true
|
||||
log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extismLogger is a helper to log messages from Extism plugins
|
||||
|
||||
@@ -24,10 +24,9 @@ type serviceContext struct {
|
||||
manager *Manager
|
||||
permissions *Permissions
|
||||
config map[string]string
|
||||
allowedUsers []string // User IDs this plugin can access
|
||||
allUsers bool // If true, plugin can access all users
|
||||
allowedLibraries []int // Library IDs this plugin can access
|
||||
allLibraries bool // If true, plugin can access all libraries
|
||||
userAccess UserAccess // User authorization for this plugin
|
||||
allowedLibraries []int // Library IDs this plugin can access
|
||||
allLibraries bool // If true, plugin can access all libraries
|
||||
}
|
||||
|
||||
// hostServiceEntry defines a host service for table-driven registration.
|
||||
@@ -52,7 +51,7 @@ var hostServices = []hostServiceEntry{
|
||||
name: "SubsonicAPI",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
||||
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.userAccess)
|
||||
return host.RegisterSubsonicAPIHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
@@ -115,7 +114,7 @@ var hostServices = []hostServiceEntry{
|
||||
name: "Users",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
|
||||
service := newUsersService(ctx.manager.ds, ctx.userAccess)
|
||||
return host.RegisterUsersHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
@@ -302,13 +301,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
var hostFunctions []extism.HostFunction
|
||||
var closers []io.Closer
|
||||
|
||||
userAccess := NewUserAccess(p.AllUsers, allowedUsers)
|
||||
|
||||
svcCtx := &serviceContext{
|
||||
pluginName: p.ID,
|
||||
manager: m,
|
||||
permissions: pkg.Manifest.Permissions,
|
||||
config: pluginConfig,
|
||||
allowedUsers: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
userAccess: userAccess,
|
||||
allowedLibraries: allowedLibraries,
|
||||
allLibraries: p.AllLibraries,
|
||||
}
|
||||
@@ -361,15 +361,14 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = &plugin{
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
metrics: m.metrics,
|
||||
allowedUserIDs: allowedUsers,
|
||||
allUsers: p.AllUsers,
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
compiled: compiled,
|
||||
capabilities: capabilities,
|
||||
closers: closers,
|
||||
metrics: m.metrics,
|
||||
userAccess: userAccess,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
@@ -12,15 +12,14 @@ import (
|
||||
|
||||
// plugin represents a loaded plugin
|
||||
type plugin struct {
|
||||
name string // Plugin name (from filename)
|
||||
path string // Path to the wasm file
|
||||
manifest *Manifest
|
||||
compiled *extism.CompiledPlugin
|
||||
capabilities []Capability // Auto-detected capabilities based on exported functions
|
||||
closers []io.Closer // Cleanup functions to call on unload
|
||||
metrics PluginMetricsRecorder
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
name string // Plugin name (from filename)
|
||||
path string // Path to the wasm file
|
||||
manifest *Manifest
|
||||
compiled *extism.CompiledPlugin
|
||||
capabilities []Capability // Auto-detected capabilities based on exported functions
|
||||
closers []io.Closer // Cleanup functions to call on unload
|
||||
metrics PluginMetricsRecorder
|
||||
userAccess UserAccess // User authorization for this plugin
|
||||
}
|
||||
|
||||
// instance creates a new plugin instance for the given context.
|
||||
|
||||
@@ -110,6 +110,33 @@
|
||||
},
|
||||
"users": {
|
||||
"$ref": "#/$defs/UsersPermission"
|
||||
},
|
||||
"endpoints": {
|
||||
"$ref": "#/$defs/EndpointsPermission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"EndpointsPermission": {
|
||||
"type": "object",
|
||||
"description": "HTTP endpoint permissions for registering custom HTTP endpoints on the Navidrome server. Requires 'users' permission when auth is 'native' or 'subsonic'.",
|
||||
"additionalProperties": false,
|
||||
"required": ["auth"],
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why HTTP endpoint registration is needed"
|
||||
},
|
||||
"auth": {
|
||||
"type": "string",
|
||||
"enum": ["native", "subsonic", "none"],
|
||||
"description": "Authentication type for plugin endpoints: 'native' (JWT), 'subsonic' (params), or 'none' (public/unauthenticated)"
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"description": "Declared endpoint paths (informational, for admin UI display). Relative to plugin base URL.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,6 +32,15 @@ func (m *Manifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoints permission with auth 'native' or 'subsonic' requires users permission
|
||||
if m.Permissions != nil && m.Permissions.Endpoints != nil {
|
||||
if m.Permissions.Endpoints.Auth != EndpointsPermissionAuthNone {
|
||||
if m.Permissions.Users == nil {
|
||||
return fmt.Errorf("'endpoints' permission with auth '%s' requires 'users' permission to be declared", m.Permissions.Endpoints.Auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate config schema if present
|
||||
if m.Config != nil && m.Config.Schema != nil {
|
||||
if err := validateConfigSchema(m.Config.Schema); err != nil {
|
||||
@@ -64,6 +73,14 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||
return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPEndpoint capability requires endpoints permission
|
||||
if hasCapability(capabilities, CapabilityHTTPEndpoint) {
|
||||
if m.Permissions == nil || m.Permissions.Endpoints == nil {
|
||||
return fmt.Errorf("HTTP endpoint capability requires 'endpoints' permission to be declared in manifest")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ package plugins
|
||||
|
||||
import "encoding/json"
|
||||
import "fmt"
|
||||
import "reflect"
|
||||
|
||||
// Artwork service permissions for generating artwork URLs
|
||||
type ArtworkPermission struct {
|
||||
@@ -45,6 +46,71 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTTP endpoint permissions for registering custom HTTP endpoints on the Navidrome
|
||||
// server. Requires 'users' permission when auth is 'native' or 'subsonic'.
|
||||
type EndpointsPermission struct {
|
||||
// Authentication type for plugin endpoints: 'native' (JWT), 'subsonic' (params),
|
||||
// or 'none' (public/unauthenticated)
|
||||
Auth EndpointsPermissionAuth `json:"auth" yaml:"auth" mapstructure:"auth"`
|
||||
|
||||
// Declared endpoint paths (informational, for admin UI display). Relative to
|
||||
// plugin base URL.
|
||||
Paths []string `json:"paths,omitempty" yaml:"paths,omitempty" mapstructure:"paths,omitempty"`
|
||||
|
||||
// Explanation for why HTTP endpoint registration is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type EndpointsPermissionAuth string
|
||||
|
||||
const EndpointsPermissionAuthNative EndpointsPermissionAuth = "native"
|
||||
const EndpointsPermissionAuthNone EndpointsPermissionAuth = "none"
|
||||
const EndpointsPermissionAuthSubsonic EndpointsPermissionAuth = "subsonic"
|
||||
|
||||
var enumValues_EndpointsPermissionAuth = []interface{}{
|
||||
"native",
|
||||
"subsonic",
|
||||
"none",
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *EndpointsPermissionAuth) UnmarshalJSON(value []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(value, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
var ok bool
|
||||
for _, expected := range enumValues_EndpointsPermissionAuth {
|
||||
if reflect.DeepEqual(v, expected) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_EndpointsPermissionAuth, v)
|
||||
}
|
||||
*j = EndpointsPermissionAuth(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *EndpointsPermission) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := raw["auth"]; raw != nil && !ok {
|
||||
return fmt.Errorf("field auth in EndpointsPermission: required")
|
||||
}
|
||||
type Plain EndpointsPermission
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
*j = EndpointsPermission(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Experimental features that may change or be removed in future versions
|
||||
type Experimental struct {
|
||||
// Threads corresponds to the JSON schema field "threads".
|
||||
@@ -166,6 +232,9 @@ type Permissions struct {
|
||||
// Cache corresponds to the JSON schema field "cache".
|
||||
Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"`
|
||||
|
||||
// Endpoints corresponds to the JSON schema field "endpoints".
|
||||
Endpoints *EndpointsPermission `json:"endpoints,omitempty" yaml:"endpoints,omitempty" mapstructure:"endpoints,omitempty"`
|
||||
|
||||
// Http corresponds to the JSON schema field "http".
|
||||
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
|
||||
|
||||
|
||||
@@ -6,3 +6,10 @@ require (
|
||||
github.com/extism/go-pdk v1.1.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
126
plugins/pdk/go/httpendpoint/httpendpoint.go
Normal file
126
plugins/pdk/go/httpendpoint/httpendpoint.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the HTTPEndpoint capability.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package httpendpoint
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
type HTTPHandleRequest struct {
|
||||
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
Method string `json:"method"`
|
||||
// Path is the request path relative to the plugin's base URL.
|
||||
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
Path string `json:"path"`
|
||||
// Query is the raw query string without the leading '?'.
|
||||
Query string `json:"query,omitempty"`
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body []byte `json:"-"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
type HTTPHandleResponse struct {
|
||||
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
Status int32 `json:"status,omitempty"`
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
// HTTPUser contains authenticated user information passed to the plugin.
|
||||
type HTTPUser struct {
|
||||
// ID is the internal Navidrome user ID.
|
||||
ID string `json:"id"`
|
||||
// Username is the user's login name.
|
||||
Username string `json:"username"`
|
||||
// Name is the user's display name.
|
||||
Name string `json:"name"`
|
||||
// IsAdmin indicates whether the user has admin privileges.
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// HTTPEndpoint requires all methods to be implemented.
|
||||
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||
type HTTPEndpoint interface {
|
||||
// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response.
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
} // Internal implementation holders
|
||||
var (
|
||||
handleRequestImpl func(HTTPHandleRequest) (HTTPHandleResponse, error)
|
||||
)
|
||||
|
||||
// Register registers a httpendpoint implementation.
|
||||
// All methods are required.
|
||||
func Register(impl HTTPEndpoint) {
|
||||
handleRequestImpl = impl.HandleRequest
|
||||
}
|
||||
|
||||
// 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_http_handle_request
|
||||
func _NdHttpHandleRequest() int32 {
|
||||
if handleRequestImpl == nil {
|
||||
// Return standard code - host will skip this plugin gracefully
|
||||
return NotImplementedCode
|
||||
}
|
||||
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
raw := pdk.Input()
|
||||
if len(raw) < 4 {
|
||||
pdk.SetErrorString("malformed input frame")
|
||||
return -1
|
||||
}
|
||||
jsonLen := binary.BigEndian.Uint32(raw[:4])
|
||||
if uint32(len(raw)-4) < jsonLen {
|
||||
pdk.SetErrorString("invalid json length in input frame")
|
||||
return -1
|
||||
}
|
||||
var input HTTPHandleRequest
|
||||
if err := json.Unmarshal(raw[4:4+jsonLen], &input); err != nil {
|
||||
pdk.SetError(err)
|
||||
return -1
|
||||
}
|
||||
input.Body = raw[4+jsonLen:]
|
||||
|
||||
output, err := handleRequestImpl(input)
|
||||
if err != nil {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
errMsg := []byte(err.Error())
|
||||
errFrame := make([]byte, 1+len(errMsg))
|
||||
errFrame[0] = 0x01
|
||||
copy(errFrame[1:], errMsg)
|
||||
pdk.Output(errFrame)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
jsonBytes, _ := json.Marshal(output)
|
||||
rawBytes := output.Body
|
||||
frame := make([]byte, 1+4+len(jsonBytes)+len(rawBytes))
|
||||
frame[0] = 0x00
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(jsonBytes)))
|
||||
copy(frame[5:5+len(jsonBytes)], jsonBytes)
|
||||
copy(frame[5+len(jsonBytes):], rawBytes)
|
||||
pdk.Output(frame)
|
||||
|
||||
return 0
|
||||
}
|
||||
65
plugins/pdk/go/httpendpoint/httpendpoint_stub.go
Normal file
65
plugins/pdk/go/httpendpoint/httpendpoint_stub.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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 httpendpoint
|
||||
|
||||
// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
type HTTPHandleRequest struct {
|
||||
// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
Method string `json:"method"`
|
||||
// Path is the request path relative to the plugin's base URL.
|
||||
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
Path string `json:"path"`
|
||||
// Query is the raw query string without the leading '?'.
|
||||
Query string `json:"query,omitempty"`
|
||||
// Headers contains the HTTP request headers.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the request body content.
|
||||
Body []byte `json:"-"`
|
||||
// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
User *HTTPUser `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
type HTTPHandleResponse struct {
|
||||
// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
Status int32 `json:"status,omitempty"`
|
||||
// Headers contains the HTTP response headers to set.
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
// Body is the response body content.
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
// HTTPUser contains authenticated user information passed to the plugin.
|
||||
type HTTPUser struct {
|
||||
// ID is the internal Navidrome user ID.
|
||||
ID string `json:"id"`
|
||||
// Username is the user's login name.
|
||||
Username string `json:"username"`
|
||||
// Name is the user's display name.
|
||||
Name string `json:"name"`
|
||||
// IsAdmin indicates whether the user has admin privileges.
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
// HTTPEndpoint requires all methods to be implemented.
|
||||
// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||
// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||
// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||
type HTTPEndpoint interface {
|
||||
// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response.
|
||||
HandleRequest(HTTPHandleRequest) (HTTPHandleResponse, 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(_ HTTPEndpoint) {}
|
||||
156
plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs
Normal file
156
plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains export wrappers for the HTTPEndpoint capability.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 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 }
|
||||
/// HTTPHandleRequest is the input provided when an HTTP request is dispatched to a plugin.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HTTPHandleRequest {
|
||||
/// Method is the HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
/// Path is the request path relative to the plugin's base URL.
|
||||
/// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
|
||||
/// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
/// Query is the raw query string without the leading '?'.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub query: String,
|
||||
/// Headers contains the HTTP request headers.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub headers: std::collections::HashMap<String, Vec<String>>,
|
||||
/// Body is the request body content.
|
||||
#[serde(skip)]
|
||||
pub body: Vec<u8>,
|
||||
/// User contains the authenticated user information. Nil for auth:"none" endpoints.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<HTTPUser>,
|
||||
}
|
||||
/// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HTTPHandleResponse {
|
||||
/// Status is the HTTP status code. Defaults to 200 if zero or not set.
|
||||
#[serde(default, skip_serializing_if = "is_zero_i32")]
|
||||
pub status: i32,
|
||||
/// Headers contains the HTTP response headers to set.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub headers: std::collections::HashMap<String, Vec<String>>,
|
||||
/// Body is the response body content.
|
||||
#[serde(skip)]
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
/// HTTPUser contains authenticated user information passed to the plugin.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HTTPUser {
|
||||
/// ID is the internal Navidrome user ID.
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
/// Username is the user's login name.
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// Name is the user's display name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// IsAdmin indicates whether the user has admin privileges.
|
||||
#[serde(default)]
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTPEndpoint requires all methods to be implemented.
|
||||
/// HTTPEndpoint allows plugins to handle incoming HTTP requests.
|
||||
/// Plugins that declare the 'endpoints' permission must implement this capability.
|
||||
/// The host dispatches incoming HTTP requests to the plugin's HandleRequest function.
|
||||
pub trait HTTPEndpoint {
|
||||
/// HandleRequest - HandleRequest processes an incoming HTTP request and returns a response.
|
||||
fn handle_request(&self, req: HTTPHandleRequest) -> Result<HTTPHandleResponse, Error>;
|
||||
}
|
||||
|
||||
/// Register all exports for the HTTPEndpoint capability.
|
||||
/// This macro generates the WASM export functions for all trait methods.
|
||||
#[macro_export]
|
||||
macro_rules! register_httpendpoint {
|
||||
($plugin_type:ty) => {
|
||||
#[extism_pdk::plugin_fn]
|
||||
pub fn nd_http_handle_request(
|
||||
_raw_input: extism_pdk::Raw<Vec<u8>>
|
||||
) -> extism_pdk::FnResult<extism_pdk::Raw<Vec<u8>>> {
|
||||
let plugin = <$plugin_type>::default();
|
||||
// Parse input frame: [json_len:4B][JSON without []byte field][raw bytes]
|
||||
let raw_bytes = _raw_input.0;
|
||||
if raw_bytes.len() < 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"malformed input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let json_len = u32::from_be_bytes([raw_bytes[0], raw_bytes[1], raw_bytes[2], raw_bytes[3]]) as usize;
|
||||
if json_len > raw_bytes.len() - 4 {
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(b"invalid json length in input frame");
|
||||
return Ok(extism_pdk::Raw(err_frame));
|
||||
}
|
||||
let mut req: $crate::httpendpoint::HTTPHandleRequest = serde_json::from_slice(&raw_bytes[4..4+json_len])
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
req.body = raw_bytes[4+json_len..].to_vec();
|
||||
match $crate::httpendpoint::HTTPEndpoint::handle_request(&plugin, req) {
|
||||
Ok(output) => {
|
||||
// Success frame: [0x00][json_len:4B][JSON without []byte field][raw bytes]
|
||||
let json_bytes = serde_json::to_vec(&output)
|
||||
.map_err(|e| extism_pdk::Error::msg(e.to_string()))?;
|
||||
let raw_field = &output.body;
|
||||
let mut frame = Vec::with_capacity(1 + 4 + json_bytes.len() + raw_field.len());
|
||||
frame.push(0x00);
|
||||
frame.extend_from_slice(&(json_bytes.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&json_bytes);
|
||||
frame.extend_from_slice(raw_field);
|
||||
Ok(extism_pdk::Raw(frame))
|
||||
}
|
||||
Err(e) => {
|
||||
// Error frame: [0x01][UTF-8 error message]
|
||||
let mut err_frame = vec![0x01u8];
|
||||
err_frame.extend_from_slice(e.message.as_bytes());
|
||||
Ok(extism_pdk::Raw(err_frame))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! This crate provides type definitions, traits, and registration macros
|
||||
//! for implementing Navidrome plugin capabilities in Rust.
|
||||
|
||||
pub mod httpendpoint;
|
||||
pub mod lifecycle;
|
||||
pub mod metadata;
|
||||
pub mod scheduler;
|
||||
|
||||
@@ -33,11 +33,8 @@ func init() {
|
||||
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
|
||||
// the scrobbler.Scrobbler interface for scrobbling to external services.
|
||||
type ScrobblerPlugin struct {
|
||||
name string
|
||||
plugin *plugin
|
||||
allowedUserIDs []string // User IDs this plugin can access (from DB configuration)
|
||||
allUsers bool // If true, plugin can access all users
|
||||
userIDMap map[string]struct{} // Cached map for fast lookups
|
||||
name string
|
||||
plugin *plugin
|
||||
}
|
||||
|
||||
// IsAuthorized checks if the user is authorized with this scrobbler.
|
||||
@@ -45,7 +42,7 @@ type ScrobblerPlugin struct {
|
||||
// then delegates to the plugin for service-specific authorization.
|
||||
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
// First check server-side authorization based on plugin configuration
|
||||
if !s.isUserAllowed(userId) {
|
||||
if !s.plugin.userAccess.IsAllowed(userId) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -63,18 +60,6 @@ func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool
|
||||
return result
|
||||
}
|
||||
|
||||
// isUserAllowed checks if the given user ID is allowed to use this plugin.
|
||||
func (s *ScrobblerPlugin) isUserAllowed(userId string) bool {
|
||||
if s.allUsers {
|
||||
return true
|
||||
}
|
||||
if len(s.allowedUserIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
_, ok := s.userIDMap[userId]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NowPlaying sends a now playing notification to the scrobbler
|
||||
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
username := getUsernameFromContext(ctx)
|
||||
|
||||
@@ -71,41 +71,6 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isUserAllowed", func() {
|
||||
It("returns true when allUsers is true", func() {
|
||||
sp := &ScrobblerPlugin{allUsers: true}
|
||||
Expect(sp.isUserAllowed("any-user")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when allowedUserIDs is empty and allUsers is false", func() {
|
||||
sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}}
|
||||
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when allowedUserIDs is nil and allUsers is false", func() {
|
||||
sp := &ScrobblerPlugin{allUsers: false}
|
||||
Expect(sp.isUserAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when user is in allowedUserIDs", func() {
|
||||
sp := &ScrobblerPlugin{
|
||||
allUsers: false,
|
||||
allowedUserIDs: []string{"user-1", "user-2"},
|
||||
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
|
||||
}
|
||||
Expect(sp.isUserAllowed("user-1")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when user is not in allowedUserIDs", func() {
|
||||
sp := &ScrobblerPlugin{
|
||||
allUsers: false,
|
||||
allowedUserIDs: []string{"user-1", "user-2"},
|
||||
userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}},
|
||||
}
|
||||
Expect(sp.isUserAllowed("user-3")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
It("successfully calls the plugin", func() {
|
||||
track := &model.MediaFile{
|
||||
|
||||
16
plugins/testdata/test-http-endpoint-native/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint-native/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
module test-http-endpoint-native
|
||||
|
||||
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-http-endpoint-native/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint-native/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=
|
||||
61
plugins/testdata/test-http-endpoint-native/main.go
vendored
Normal file
61
plugins/testdata/test-http-endpoint-native/main.go
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
// Test plugin for native auth (JWT) HTTP endpoint integration tests.
|
||||
// Build with: tinygo build -o ../test-http-endpoint-native.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpendpoint.Register(&testNativeEndpoint{})
|
||||
}
|
||||
|
||||
type testNativeEndpoint struct{}
|
||||
|
||||
func (t *testNativeEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||
switch req.Path {
|
||||
case "/hello":
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: []byte("Hello from native auth plugin!"),
|
||||
}, nil
|
||||
|
||||
case "/echo":
|
||||
// Echo back the request as JSON
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": req.Query,
|
||||
"body": string(req.Body),
|
||||
"hasUser": req.User != nil,
|
||||
"username": userName(req.User),
|
||||
})
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: data,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: []byte("Not found: " + req.Path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func userName(u *httpendpoint.HTTPUser) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func main() {}
|
||||
16
plugins/testdata/test-http-endpoint-native/manifest.json
vendored
Normal file
16
plugins/testdata/test-http-endpoint-native/manifest.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Test HTTP Endpoint Native Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin for native (JWT) HTTP endpoint integration testing",
|
||||
"permissions": {
|
||||
"endpoints": {
|
||||
"auth": "native",
|
||||
"paths": ["/hello", "/echo"],
|
||||
"reason": "Testing native auth HTTP endpoint handling"
|
||||
},
|
||||
"users": {
|
||||
"reason": "Authenticated endpoints require user access"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
plugins/testdata/test-http-endpoint-public/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint-public/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
module test-http-endpoint-public
|
||||
|
||||
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-http-endpoint-public/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint-public/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=
|
||||
45
plugins/testdata/test-http-endpoint-public/main.go
vendored
Normal file
45
plugins/testdata/test-http-endpoint-public/main.go
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
// Test plugin for public (unauthenticated) HTTP endpoint integration tests.
|
||||
// Build with: tinygo build -o ../test-http-endpoint-public.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpendpoint.Register(&testPublicEndpoint{})
|
||||
}
|
||||
|
||||
type testPublicEndpoint struct{}
|
||||
|
||||
func (t *testPublicEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||
switch req.Path {
|
||||
case "/webhook":
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: []byte("webhook received"),
|
||||
}, nil
|
||||
|
||||
case "/check-no-user":
|
||||
// Verify that no user info is provided for public endpoints
|
||||
hasUser := "false"
|
||||
if req.User != nil {
|
||||
hasUser = "true"
|
||||
}
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Body: []byte("hasUser=" + hasUser),
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: []byte("Not found: " + req.Path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func main() {}
|
||||
13
plugins/testdata/test-http-endpoint-public/manifest.json
vendored
Normal file
13
plugins/testdata/test-http-endpoint-public/manifest.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Test HTTP Endpoint Public Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin for public (unauthenticated) HTTP endpoint integration testing",
|
||||
"permissions": {
|
||||
"endpoints": {
|
||||
"auth": "none",
|
||||
"paths": ["/webhook"],
|
||||
"reason": "Testing public HTTP endpoints"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
plugins/testdata/test-http-endpoint/go.mod
vendored
Normal file
16
plugins/testdata/test-http-endpoint/go.mod
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
module test-http-endpoint
|
||||
|
||||
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-http-endpoint/go.sum
vendored
Normal file
14
plugins/testdata/test-http-endpoint/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=
|
||||
77
plugins/testdata/test-http-endpoint/main.go
vendored
Normal file
77
plugins/testdata/test-http-endpoint/main.go
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
// Test plugin for HTTP endpoint integration tests.
|
||||
// Build with: tinygo build -o ../test-http-endpoint.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/httpendpoint"
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpendpoint.Register(&testEndpoint{})
|
||||
}
|
||||
|
||||
type testEndpoint struct{}
|
||||
|
||||
func (t *testEndpoint) HandleRequest(req httpendpoint.HTTPHandleRequest) (httpendpoint.HTTPHandleResponse, error) {
|
||||
switch req.Path {
|
||||
case "/hello":
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
Body: []byte("Hello from plugin!"),
|
||||
}, nil
|
||||
|
||||
case "/echo":
|
||||
// Echo back the request as JSON
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": req.Query,
|
||||
"body": string(req.Body),
|
||||
"hasUser": req.User != nil,
|
||||
"username": userName(req.User),
|
||||
})
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
},
|
||||
Body: data,
|
||||
}, nil
|
||||
|
||||
case "/binary":
|
||||
// Return raw binary data (PNG header)
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 200,
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"image/png"},
|
||||
},
|
||||
Body: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
|
||||
}, nil
|
||||
|
||||
case "/error":
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 500,
|
||||
Body: []byte("Something went wrong"),
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return httpendpoint.HTTPHandleResponse{
|
||||
Status: 404,
|
||||
Body: []byte("Not found: " + req.Path),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func userName(u *httpendpoint.HTTPUser) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func main() {}
|
||||
16
plugins/testdata/test-http-endpoint/manifest.json
vendored
Normal file
16
plugins/testdata/test-http-endpoint/manifest.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Test HTTP Endpoint Plugin",
|
||||
"author": "Navidrome Test",
|
||||
"version": "1.0.0",
|
||||
"description": "Test plugin for HTTP endpoint integration testing",
|
||||
"permissions": {
|
||||
"endpoints": {
|
||||
"auth": "subsonic",
|
||||
"paths": ["/hello", "/echo"],
|
||||
"reason": "Testing HTTP endpoint handling"
|
||||
},
|
||||
"users": {
|
||||
"reason": "Authenticated endpoints require user access"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
plugins/user_access.go
Normal file
35
plugins/user_access.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package plugins
|
||||
|
||||
// UserAccess encapsulates user authorization for a plugin,
|
||||
// determining which users are allowed to interact with it.
|
||||
type UserAccess struct {
|
||||
allUsers bool
|
||||
userIDMap map[string]struct{}
|
||||
}
|
||||
|
||||
// NewUserAccess creates a UserAccess from the plugin's configuration.
|
||||
// If allUsers is true, all users are allowed regardless of the list.
|
||||
func NewUserAccess(allUsers bool, userIDs []string) UserAccess {
|
||||
userIDMap := make(map[string]struct{}, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
userIDMap[id] = struct{}{}
|
||||
}
|
||||
return UserAccess{
|
||||
allUsers: allUsers,
|
||||
userIDMap: userIDMap,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed checks if the given user ID is permitted.
|
||||
func (ua UserAccess) IsAllowed(userID string) bool {
|
||||
if ua.allUsers {
|
||||
return true
|
||||
}
|
||||
_, ok := ua.userIDMap[userID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// HasConfiguredUsers reports whether any specific user IDs have been configured.
|
||||
func (ua UserAccess) HasConfiguredUsers() bool {
|
||||
return ua.allUsers || len(ua.userIDMap) > 0
|
||||
}
|
||||
64
plugins/user_access_test.go
Normal file
64
plugins/user_access_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("UserAccess", func() {
|
||||
Describe("IsAllowed", func() {
|
||||
It("returns true when allUsers is true", func() {
|
||||
ua := NewUserAccess(true, nil)
|
||||
Expect(ua.IsAllowed("any-user")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true when allUsers is true even with an explicit list", func() {
|
||||
ua := NewUserAccess(true, []string{"user-1"})
|
||||
Expect(ua.IsAllowed("other-user")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when userIDs is empty", func() {
|
||||
ua := NewUserAccess(false, []string{})
|
||||
Expect(ua.IsAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when userIDs is nil", func() {
|
||||
ua := NewUserAccess(false, nil)
|
||||
Expect(ua.IsAllowed("user-1")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when user is in the list", func() {
|
||||
ua := NewUserAccess(false, []string{"user-1", "user-2"})
|
||||
Expect(ua.IsAllowed("user-1")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when user is not in the list", func() {
|
||||
ua := NewUserAccess(false, []string{"user-1", "user-2"})
|
||||
Expect(ua.IsAllowed("user-3")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("HasConfiguredUsers", func() {
|
||||
It("returns true when allUsers is true", func() {
|
||||
ua := NewUserAccess(true, nil)
|
||||
Expect(ua.HasConfiguredUsers()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true when specific users are configured", func() {
|
||||
ua := NewUserAccess(false, []string{"user-1"})
|
||||
Expect(ua.HasConfiguredUsers()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false when no users are configured", func() {
|
||||
ua := NewUserAccess(false, nil)
|
||||
Expect(ua.HasConfiguredUsers()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when user list is empty", func() {
|
||||
ua := NewUserAccess(false, []string{})
|
||||
Expect(ua.HasConfiguredUsers()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -333,76 +333,76 @@
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"actions": {
|
||||
"addConfig": "Tilføj konfiguration",
|
||||
"disable": "Deaktivér",
|
||||
"disabledDueToError": "Ret fejlen før aktivering",
|
||||
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
|
||||
"disabledUsersRequired": "Vælg brugere før aktivering",
|
||||
"enable": "Aktivér",
|
||||
"rescan": "Genskan"
|
||||
},
|
||||
"fields": {
|
||||
"allLibraries": "Tillad alle biblioteker",
|
||||
"allUsers": "Tillad alle brugere",
|
||||
"id": "ID",
|
||||
"name": "Navn",
|
||||
"description": "Beskrivelse",
|
||||
"version": "Version",
|
||||
"author": "Forfatter",
|
||||
"website": "Hjemmeside",
|
||||
"permissions": "Tilladelser",
|
||||
"enabled": "Aktiveret",
|
||||
"status": "Status",
|
||||
"path": "Sti",
|
||||
"lastError": "Fejl",
|
||||
"hasError": "Fejl",
|
||||
"updatedAt": "Opdateret",
|
||||
"createdAt": "Installeret",
|
||||
"configKey": "Nøgle",
|
||||
"configValue": "Værdi",
|
||||
"createdAt": "Installeret",
|
||||
"description": "Beskrivelse",
|
||||
"enabled": "Aktiveret",
|
||||
"hasError": "Fejl",
|
||||
"id": "ID",
|
||||
"lastError": "Fejl",
|
||||
"name": "Navn",
|
||||
"path": "Sti",
|
||||
"permissions": "Tilladelser",
|
||||
"selectedLibraries": "Valgte biblioteker",
|
||||
"allUsers": "Tillad alle brugere",
|
||||
"selectedUsers": "Valgte brugere",
|
||||
"status": "Status",
|
||||
"updatedAt": "Opdateret",
|
||||
"version": "Version",
|
||||
"website": "Hjemmeside"
|
||||
"allLibraries": "Tillad alle biblioteker",
|
||||
"selectedLibraries": "Valgte biblioteker"
|
||||
},
|
||||
"messages": {
|
||||
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
|
||||
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
|
||||
"clickPermissions": "Klik på en tilladelse for detaljer",
|
||||
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
|
||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
||||
"noConfig": "Ingen konfiguration angivet",
|
||||
"noLibraries": "Ingen biblioteker valgt",
|
||||
"noUsers": "Ingen brugere valgt",
|
||||
"permissionReason": "Årsag",
|
||||
"requiredHosts": "Påkrævede værter",
|
||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
|
||||
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'."
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Pluginoplysninger",
|
||||
"configuration": "Konfiguration",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Brugertilladelse",
|
||||
"libraryPermission": "Bibliotekstilladelse"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aktiveret",
|
||||
"disabled": "Deaktiveret"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktivér",
|
||||
"disable": "Deaktivér",
|
||||
"disabledDueToError": "Ret fejlen før aktivering",
|
||||
"disabledUsersRequired": "Vælg brugere før aktivering",
|
||||
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
|
||||
"addConfig": "Tilføj konfiguration",
|
||||
"rescan": "Genskan"
|
||||
},
|
||||
"notifications": {
|
||||
"disabled": "Plugin deaktiveret",
|
||||
"enabled": "Plugin aktiveret",
|
||||
"error": "Fejl ved opdatering af plugin",
|
||||
"updated": "Plugin opdateret"
|
||||
"disabled": "Plugin deaktiveret",
|
||||
"updated": "Plugin opdateret",
|
||||
"error": "Fejl ved opdatering af plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurationen skal være gyldig JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
|
||||
"clickPermissions": "Klik på en tilladelse for detaljer",
|
||||
"noConfig": "Ingen konfiguration angivet",
|
||||
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
|
||||
"noUsers": "Ingen brugere valgt",
|
||||
"permissionReason": "Årsag",
|
||||
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.",
|
||||
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
|
||||
"noLibraries": "Ingen biblioteker valgt",
|
||||
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
||||
"requiredHosts": "Påkrævede hosts",
|
||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "nøgle",
|
||||
"configValue": "værdi"
|
||||
},
|
||||
"sections": {
|
||||
"configuration": "Konfiguration",
|
||||
"info": "Pluginoplysninger",
|
||||
"libraryPermission": "Bibliotekstilladelse",
|
||||
"manifest": "Manifest",
|
||||
"status": "Status",
|
||||
"usersPermission": "Brugertilladelse"
|
||||
},
|
||||
"status": {
|
||||
"disabled": "Deaktiveret",
|
||||
"enabled": "Aktiveret"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurationen skal være gyldig JSON"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -674,7 +674,8 @@
|
||||
"exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
|
||||
"exportFailed": "Kunne ikke kopiere konfigurationen",
|
||||
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
|
||||
"downloadToml": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"languageName": "Euskara",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Abestia |||| Abestiak",
|
||||
"name": "Abestia |||| Abesti",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"duration": "Iraupena",
|
||||
@@ -10,6 +10,7 @@
|
||||
"playCount": "Erreprodukzioak",
|
||||
"title": "Titulua",
|
||||
"artist": "Artista",
|
||||
"composer": "Konpositorea",
|
||||
"album": "Albuma",
|
||||
"path": "Fitxategiaren bidea",
|
||||
"libraryName": "Liburutegia",
|
||||
@@ -33,9 +34,9 @@
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak",
|
||||
"tags": "Etiketa gehiago",
|
||||
"mappedTags": "Esleitutako etiketak",
|
||||
"rawTags": "Etiketa gordinak",
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"actions": {
|
||||
@@ -46,11 +47,12 @@
|
||||
"shuffleAll": "Erreprodukzio aleatorioa",
|
||||
"download": "Deskargatu",
|
||||
"playNext": "Hurrengoa",
|
||||
"info": "Erakutsi informazioa"
|
||||
"info": "Erakutsi informazioa",
|
||||
"instantMix": "Berehalako nahastea"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albuma |||| Albumak",
|
||||
"name": "Albuma |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"artist": "Artista",
|
||||
@@ -66,7 +68,7 @@
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"releases": "Argitaratzea |||| Argitaratze",
|
||||
"released": "Argitaratua",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
@@ -101,7 +103,7 @@
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artistak",
|
||||
"name": "Artista |||| Artista",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
@@ -330,6 +332,80 @@
|
||||
"scanInProgress": "Araketa abian da…",
|
||||
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugina |||| Plugin",
|
||||
"fields": {
|
||||
"id": "IDa",
|
||||
"name": "Izena",
|
||||
"description": "Deskribapena",
|
||||
"version": "Bertsioa",
|
||||
"author": "Autorea",
|
||||
"website": "Webgunea",
|
||||
"permissions": "Baimenak",
|
||||
"enabled": "Gaituta",
|
||||
"status": "Egoera",
|
||||
"path": "Bidea",
|
||||
"lastError": "Errorea",
|
||||
"hasError": "Errorea",
|
||||
"updatedAt": "Eguneratuta",
|
||||
"createdAt": "Instalatuta",
|
||||
"configKey": "Gakoa",
|
||||
"configValue": "Balioa",
|
||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||
"allLibraries": "Baimendu liburutegi guztiak",
|
||||
"selectedLibraries": "Hautatutako liburutegiak"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Egoera",
|
||||
"info": "Pluginaren informazioa",
|
||||
"configuration": "Konfigurazioa",
|
||||
"manifest": "Manifestua",
|
||||
"usersPermission": "Erabiltzaileen baimenak",
|
||||
"libraryPermission": "Liburutegien baimenak"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Gaituta",
|
||||
"disabled": "Ezgaituta"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Gaitu",
|
||||
"disable": "Ezgaitu",
|
||||
"disabledDueToError": "Konpondu errorea gaitu baino lehen",
|
||||
"disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen",
|
||||
"disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen",
|
||||
"addConfig": "Gehitu konfigurazioa",
|
||||
"rescan": "Arakatu berriro"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugina gaituta",
|
||||
"disabled": "Plugina ezgaituta",
|
||||
"updated": "Plugina eguneratuta",
|
||||
"error": "Errorea plugina eguneratzean"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
||||
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
||||
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
||||
"clickPermissions": "Sakatu baimen batean xehetasunetarako",
|
||||
"noConfig": "Ez da konfiguraziorik ezarri",
|
||||
"allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.",
|
||||
"noUsers": "Ez da erabiltzailerik hautatu",
|
||||
"permissionReason": "Arrazoia",
|
||||
"usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.",
|
||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||
"noLibraries": "Ez da liburutegirik hautatu",
|
||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
||||
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "gakoa",
|
||||
"configValue": "balioa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -483,6 +559,7 @@
|
||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
|
||||
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
|
||||
"startingInstantMix": "Berehalako nahastea kargatzen…",
|
||||
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"playCount": "Lejátszások",
|
||||
"title": "Cím",
|
||||
"artist": "Előadó",
|
||||
"composer": "Zeneszerző",
|
||||
"album": "Album",
|
||||
"path": "Elérési út",
|
||||
"libraryName": "Könyvtár",
|
||||
@@ -46,7 +47,8 @@
|
||||
"shuffleAll": "Keverés",
|
||||
"download": "Letöltés",
|
||||
"playNext": "Lejátszás következőként",
|
||||
"info": "Részletek"
|
||||
"info": "Részletek",
|
||||
"instantMix": "Instant keverés"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -325,6 +327,80 @@
|
||||
"scanInProgress": "Szkennelés folyamatban...",
|
||||
"noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Kiegészítő |||| Kiegészítők",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Név",
|
||||
"description": "Leírás",
|
||||
"version": "Verzió",
|
||||
"author": "Fejlesztő",
|
||||
"website": "Weboldal",
|
||||
"permissions": "Engedélyek",
|
||||
"enabled": "Engedélyezve",
|
||||
"status": "Státusz",
|
||||
"path": "Útvonal",
|
||||
"lastError": "Hiba",
|
||||
"hasError": "Hiba",
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Telepítve",
|
||||
"configKey": "Kulcs",
|
||||
"configValue": "Érték",
|
||||
"allUsers": "Összes felhasználó engedélyezése",
|
||||
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
|
||||
"allLibraries": "Összes könyvtár engedélyezése",
|
||||
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Státusz",
|
||||
"info": "Kiegészítő információi",
|
||||
"configuration": "Konfiguráció",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Felhasználói engedélyek",
|
||||
"libraryPermission": "Könyvtári engedélyek"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Engedélyezve",
|
||||
"disabled": "Letiltva"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Engedélyezés",
|
||||
"disable": "Letiltás",
|
||||
"disabledDueToError": "Javítsd ki a kiegészítő hibáját",
|
||||
"disabledUsersRequired": "Válassz felhasználókat",
|
||||
"disabledLibrariesRequired": "Válassz könyvtárakat",
|
||||
"addConfig": "Konfiguráció hozzáadása",
|
||||
"rescan": "Újraszkennelés"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Kiegészítő engedélyezve",
|
||||
"disabled": "Kiegészítő letiltva",
|
||||
"updated": "Kiegészítő frissítve",
|
||||
"error": "Hiba történt a kiegészítő frissítése közben"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "A konfigurációs JSON érvénytelen"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.",
|
||||
"configValidationError": "Helytelen konfiguráció:",
|
||||
"schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.",
|
||||
"clickPermissions": "Kattints egy engedélyre a részletekért",
|
||||
"noConfig": "Nincs konfiguráció beállítva",
|
||||
"allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.",
|
||||
"noUsers": "Nincsenek kiválasztott felhasználók",
|
||||
"permissionReason": "Indok",
|
||||
"usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.",
|
||||
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
|
||||
"noLibraries": "Nincs kiválasztott könyvtár",
|
||||
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
|
||||
"requiredHosts": "Szükséges hostok"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "kulcs",
|
||||
"configValue": "érték"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -402,7 +478,7 @@
|
||||
"loading": "Betöltés",
|
||||
"not_found": "Nem található",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nincs %{name} még.",
|
||||
"empty": "Nincsenek %{name}.",
|
||||
"invite": "Szeretnél egyet hozzáadni?"
|
||||
},
|
||||
"input": {
|
||||
@@ -478,6 +554,7 @@
|
||||
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
|
||||
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
|
||||
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
|
||||
"startingInstantMix": "Instant keverés töltődik...",
|
||||
"noTopSongsFound": "Nincsenek top számok",
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
@@ -591,6 +668,7 @@
|
||||
"currentValue": "Jelenlegi érték",
|
||||
"configurationFile": "Konfigurációs fájl",
|
||||
"exportToml": "Konfiguráció exportálása (TOML)",
|
||||
"downloadToml": "Konfiguráció letöltése (TOML)",
|
||||
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
|
||||
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
|
||||
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",
|
||||
|
||||
@@ -674,7 +674,8 @@
|
||||
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
|
||||
"exportFailed": "Falha ao copiar configuração",
|
||||
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras",
|
||||
"downloadToml": "Baixar configuração (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,10 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
)
|
||||
|
||||
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
|
||||
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
|
||||
pls core.Playlists, m metrics.Metrics) model.Scanner {
|
||||
c := &controller{
|
||||
rootCtx: rootCtx,
|
||||
ds: ds,
|
||||
@@ -53,7 +53,7 @@ func (s *controller) getScanner() scanner {
|
||||
// CallScan starts an in-process scan of specific library/folder pairs.
|
||||
// If targets is empty, it scans all libraries.
|
||||
// This is meant to be called from the command line (see cmd/scan.go).
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
|
||||
release, err := lockScan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -98,7 +98,7 @@ type controller struct {
|
||||
cw artwork.CacheWarmer
|
||||
broker events.Broker
|
||||
metrics metrics.Metrics
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
limiter *rate.Sometimes
|
||||
devExternalScanner bool
|
||||
count atomic.Uint32
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -31,7 +31,7 @@ var _ = Describe("Controller", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
It("includes last scan error", func() {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/chrono"
|
||||
)
|
||||
@@ -72,7 +72,7 @@ func (f *folderEntry) isOutdated() bool {
|
||||
func (f *folderEntry) toFolder() *model.Folder {
|
||||
folder := model.NewFolder(f.job.lib, f.path)
|
||||
folder.NumAudioFiles = len(f.audioFiles)
|
||||
if playlists.InPath(*folder) {
|
||||
if core.InPlaylistsPath(*folder) {
|
||||
folder.NumPlaylists = f.numPlaylists
|
||||
}
|
||||
folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles))
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -21,12 +21,12 @@ type phasePlaylists struct {
|
||||
ctx context.Context
|
||||
scanState *scanState
|
||||
ds model.DataStore
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
cw artwork.CacheWarmer
|
||||
refreshed atomic.Uint32
|
||||
}
|
||||
|
||||
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
|
||||
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
|
||||
return &phasePlaylists{
|
||||
ctx: ctx,
|
||||
scanState: scanState,
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -130,7 +130,7 @@ var _ = Describe("phasePlaylists", func() {
|
||||
|
||||
type mockPlaylists struct {
|
||||
mock.Mock
|
||||
playlists.Playlists
|
||||
core.Playlists
|
||||
}
|
||||
|
||||
func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
type scannerImpl struct {
|
||||
ds model.DataStore
|
||||
cw artwork.CacheWarmer
|
||||
pls playlists.Playlists
|
||||
pls core.Playlists
|
||||
}
|
||||
|
||||
// scanState holds the state of an in-progress scan, to be passed to the various phases
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) {
|
||||
ds := persistence.New(db.Db())
|
||||
conf.Server.DevExternalScanner = false
|
||||
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
fs := storagetest.FakeFS{}
|
||||
storagetest.Register("fake", &fs)
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -77,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
// Create two test libraries (let DB auto-assign IDs)
|
||||
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -63,7 +63,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -84,7 +84,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
113
server/e2e/doc.go
Normal file
113
server/e2e/doc.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API.
|
||||
//
|
||||
// These tests exercise the full HTTP request/response cycle through the Subsonic API router,
|
||||
// using a real SQLite database and real repository implementations while stubbing out external
|
||||
// services (artwork, streaming, scrobbling, etc.) with noop implementations.
|
||||
//
|
||||
// # Test Infrastructure
|
||||
//
|
||||
// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked
|
||||
// through the standard Go test entry point [TestSubsonicE2E], which initializes the test
|
||||
// environment, creates a temporary SQLite database, and runs the specs.
|
||||
//
|
||||
// # Setup and Teardown
|
||||
//
|
||||
// During [BeforeSuite], the test infrastructure:
|
||||
//
|
||||
// 1. Creates a temporary SQLite database with WAL journal mode.
|
||||
// 2. Initializes the schema via [db.Init].
|
||||
// 3. Creates two test users: an admin ("admin") and a regular user ("regular"),
|
||||
// both with the password "password".
|
||||
// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem
|
||||
// (scheme "fake:///music") using the [storagetest] package.
|
||||
// 5. Populates the filesystem with a set of test tracks spanning multiple artists,
|
||||
// albums, genres, and years.
|
||||
// 6. Runs the scanner to import all metadata into the database.
|
||||
// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation.
|
||||
//
|
||||
// # Test Data
|
||||
//
|
||||
// The fake filesystem contains the following music library structure:
|
||||
//
|
||||
// Rock/The Beatles/Abbey Road/
|
||||
// 01 - Come Together.mp3 (1969, Rock)
|
||||
// 02 - Something.mp3 (1969, Rock)
|
||||
// Rock/The Beatles/Help!/
|
||||
// 01 - Help.mp3 (1965, Rock)
|
||||
// Rock/Led Zeppelin/IV/
|
||||
// 01 - Stairway To Heaven.mp3 (1971, Rock)
|
||||
// Jazz/Miles Davis/Kind of Blue/
|
||||
// 01 - So What.mp3 (1959, Jazz)
|
||||
// Pop/
|
||||
// 01 - Standalone Track.mp3 (2020, Pop)
|
||||
//
|
||||
// # Database Isolation
|
||||
//
|
||||
// Before each top-level Describe block, the [setupTestDB] function restores the database
|
||||
// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all
|
||||
// table data from the snapshot back into the main database, providing each test group with
|
||||
// a clean, consistent starting state without the overhead of re-scanning the filesystem.
|
||||
//
|
||||
// A fresh [subsonic.Router] is also created for each test group, wired with real data store
|
||||
// repositories and noop stubs for external services:
|
||||
//
|
||||
// - noopArtwork: returns [model.ErrNotFound] for all artwork requests.
|
||||
// - noopStreamer: returns [model.ErrNotFound] for all stream requests.
|
||||
// - noopArchiver: returns [model.ErrNotFound] for all archive requests.
|
||||
// - noopProvider: returns empty results for all external metadata lookups.
|
||||
// - noopPlayTracker: silently discards all scrobble events.
|
||||
//
|
||||
// # Request Helpers
|
||||
//
|
||||
// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API
|
||||
// request with authentication parameters (username, password, API version "1.16.1", client
|
||||
// name "test-client", and JSON format). Convenience wrappers include:
|
||||
//
|
||||
// - [doReq]: sends a request as the admin user and returns the parsed JSON response.
|
||||
// - [doReqWithUser]: sends a request as a specific user.
|
||||
// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for
|
||||
// binary content or status code inspection.
|
||||
//
|
||||
// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope
|
||||
// and returns the inner response map.
|
||||
//
|
||||
// # Test Organization
|
||||
//
|
||||
// Each test file covers a logical group of Subsonic API endpoints:
|
||||
//
|
||||
// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions
|
||||
// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory,
|
||||
// getArtist, getAlbum, getSong, getGenres
|
||||
// - subsonic_searching_test.go: search2, search3
|
||||
// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2
|
||||
// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists,
|
||||
// updatePlaylist, deletePlaylist
|
||||
// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble
|
||||
// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar,
|
||||
// getLyrics, getLyricsBySongId
|
||||
// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark,
|
||||
// savePlayQueue, getPlayQueue
|
||||
// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation,
|
||||
// updateInternetRadioStation, deleteInternetRadioStation
|
||||
// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare
|
||||
// - subsonic_users_test.go: getUser, getUsers
|
||||
// - subsonic_scan_test.go: getScanStatus, startScan
|
||||
// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement
|
||||
// - subsonic_multilibrary_test.go: multi-library access control and data isolation
|
||||
//
|
||||
// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block,
|
||||
// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist
|
||||
// and then verifying it can be retrieved).
|
||||
//
|
||||
// # Running
|
||||
//
|
||||
// The e2e tests are included in the standard test suite and can be run with:
|
||||
//
|
||||
// make test PKG=./server/e2e # Run only e2e tests
|
||||
// make test # Run all tests including e2e
|
||||
// make test-race # Run with race detector
|
||||
//
|
||||
// [Ginkgo]: https://onsi.github.io/ginkgo/
|
||||
// [Gomega]: https://onsi.github.io/gomega/
|
||||
// [storagetest]: /core/storage/storagetest
|
||||
package e2e
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -70,14 +69,6 @@ var (
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
// Regular (non-admin) user for permission tests
|
||||
regularUser = model.User{
|
||||
ID: "regular-1",
|
||||
UserName: "regular",
|
||||
Name: "Regular User",
|
||||
IsAdmin: false,
|
||||
}
|
||||
)
|
||||
|
||||
func createFS(files fstest.MapFS) storagetest.FakeFS {
|
||||
@@ -297,29 +288,19 @@ var _ = BeforeSuite(func() {
|
||||
adminUserWithPass.NewPassword = "password"
|
||||
Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed())
|
||||
|
||||
regularUserWithPass := regularUser
|
||||
regularUserWithPass.NewPassword = "password"
|
||||
Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
|
||||
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
|
||||
|
||||
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
adminUser.Libraries = loadedUser.Libraries
|
||||
|
||||
loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
regularUser.Libraries = loadedRegular.Libraries
|
||||
|
||||
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
||||
|
||||
buildTestFS()
|
||||
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(initDS), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(initDS), metrics.NewNoopInstance())
|
||||
_, err = s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -353,7 +334,7 @@ func setupTestDB() {
|
||||
|
||||
// Create the Subsonic Router with real DS + noop stubs
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
router = subsonic.New(
|
||||
ds,
|
||||
noopArtwork{},
|
||||
@@ -363,7 +344,7 @@ func setupTestDB() {
|
||||
noopProvider{},
|
||||
s,
|
||||
events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds),
|
||||
core.NewPlaylists(ds),
|
||||
noopPlayTracker{},
|
||||
core.NewShare(ds),
|
||||
playback.PlaybackServer(nil),
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
@@ -53,7 +53,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
|
||||
|
||||
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
|
||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
_, err = s.ScanAll(ctx, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -19,9 +15,9 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
setupTestDB()
|
||||
|
||||
// Look up song IDs from scanned data for playlist operations
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(songs)).To(BeNumerically(">=", 5))
|
||||
Expect(len(songs)).To(BeNumerically(">=", 3))
|
||||
for _, s := range songs {
|
||||
songIDs = append(songIDs, s.ID)
|
||||
}
|
||||
@@ -36,30 +32,24 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
})
|
||||
|
||||
It("createPlaylist creates a new playlist with songs", func() {
|
||||
resp := doReq("createPlaylist", "name", "Test Playlist",
|
||||
"songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2])
|
||||
resp := doReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
playlistID = resp.Playlist.Id
|
||||
})
|
||||
|
||||
It("getPlaylist returns playlist with tracks in order", func() {
|
||||
It("getPlaylist returns playlist with tracks", func() {
|
||||
resp := doReq("getPlaylist", "id", playlistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
||||
})
|
||||
|
||||
It("createPlaylist without name or playlistId returns error", func() {
|
||||
@@ -69,150 +59,40 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("createPlaylist with playlistId replaces tracks on existing playlist", func() {
|
||||
// Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4]
|
||||
resp := doReq("createPlaylist", "playlistId", playlistID,
|
||||
"songId", songIDs[3], "songId", songIDs[4])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.Id).To(Equal(playlistID))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can rename the playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Verify the rename
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
|
||||
Expect(resp.Playlist.Name).To(Equal("Renamed Playlist"))
|
||||
// Tracks should be unchanged
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
|
||||
It("updatePlaylist can set comment", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.Comment).To(Equal("My favorite songs"))
|
||||
})
|
||||
|
||||
It("updatePlaylist can set public visibility", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("updatePlaylist can add songs", func() {
|
||||
// Playlist currently has [song3, song4], add song0
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0])
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Verify the song was added
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can add multiple songs at once", func() {
|
||||
// Playlist currently has [song3, song4, song0], add song1 and song2
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2])
|
||||
It("updatePlaylist can remove songs by index", func() {
|
||||
// Remove the first song (index 0)
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
// Verify the song was removed
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(5)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(5))
|
||||
})
|
||||
|
||||
It("updatePlaylist can remove songs by index and verifies correct songs remain", func() {
|
||||
// Playlist has [song3, song4, song0, song1, song2]
|
||||
// Remove index 0 (song3) and index 2 (song0)
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIndexToRemove", "0", "songIndexToRemove", "2")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can remove and add songs in a single call", func() {
|
||||
// Playlist has [song4, song1, song2]
|
||||
// Remove index 1 (song1) and add song3
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIndexToRemove", "1", "songIdToAdd", songIDs[3])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(3))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2]))
|
||||
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can combine metadata change with track removal", func() {
|
||||
// Playlist has [song4, song2, song3]
|
||||
// Rename + remove index 0 (song4)
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"name", "Final Playlist", "songIndexToRemove", "0")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.Name).To(Equal("Final Playlist"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2]))
|
||||
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3]))
|
||||
})
|
||||
|
||||
It("updatePlaylist can remove all songs from playlist", func() {
|
||||
// Playlist has [song2, song3] — remove both
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIndexToRemove", "0", "songIndexToRemove", "1")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
|
||||
Expect(resp.Playlist.Entry).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("updatePlaylist can add songs to an empty playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", playlistID,
|
||||
"songIdToAdd", songIDs[0])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", playlistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(1)))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(1))
|
||||
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
|
||||
})
|
||||
|
||||
It("updatePlaylist without playlistId returns error", func() {
|
||||
resp := doReq("updatePlaylist", "name", "No ID")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("getPlaylists shows the playlist", func() {
|
||||
resp := doReq("getPlaylists")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlists.Playlist).To(HaveLen(1))
|
||||
Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID))
|
||||
Expect(resp.Playlist.Entry).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("deletePlaylist removes the playlist", func() {
|
||||
@@ -227,294 +107,4 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("getPlaylists returns empty after deletion", func() {
|
||||
resp := doReq("getPlaylists")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlists.Playlist).To(BeEmpty())
|
||||
})
|
||||
|
||||
Describe("Playlist Permissions", Ordered, func() {
|
||||
var songIDs []string
|
||||
var adminPrivateID string
|
||||
var adminPublicID string
|
||||
var regularPlaylistID string
|
||||
|
||||
BeforeAll(func() {
|
||||
setupTestDB()
|
||||
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(songs)).To(BeNumerically(">=", 3))
|
||||
for _, s := range songs {
|
||||
songIDs = append(songIDs, s.ID)
|
||||
}
|
||||
})
|
||||
|
||||
It("admin creates a private playlist", func() {
|
||||
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private",
|
||||
"songId", songIDs[0], "songId", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
adminPrivateID = resp.Playlist.Id
|
||||
})
|
||||
|
||||
It("admin creates a public playlist", func() {
|
||||
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public",
|
||||
"songId", songIDs[0], "songId", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
adminPublicID = resp.Playlist.Id
|
||||
|
||||
// Make it public
|
||||
resp = doReqWithUser(adminUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "public", "true")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
})
|
||||
|
||||
It("regular user creates a playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist",
|
||||
"songId", songIDs[0])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
regularPlaylistID = resp.Playlist.Id
|
||||
})
|
||||
|
||||
// --- Private playlist: regular user gets "not found" (repo hides it entirely) ---
|
||||
|
||||
It("regular user cannot see admin's private playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("regular user cannot update admin's private playlist (not found)", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPrivateID, "name", "Hacked")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("regular user cannot delete admin's private playlist (not found)", func() {
|
||||
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
// --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) ---
|
||||
|
||||
It("regular user can see admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
||||
})
|
||||
|
||||
It("regular user cannot update admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "name", "Hacked")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot add songs to admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "songIdToAdd", songIDs[2])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot remove songs from admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", adminPublicID, "songIndexToRemove", "0")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot delete admin's public playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist",
|
||||
"playlistId", adminPublicID, "songId", songIDs[2])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
})
|
||||
|
||||
// --- Regular user can manage their own playlists ---
|
||||
|
||||
It("regular user can update their own playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", regularPlaylistID, "name", "My Updated Playlist")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
||||
Expect(resp.Playlist.Name).To(Equal("My Updated Playlist"))
|
||||
})
|
||||
|
||||
It("regular user can add songs to their own playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "updatePlaylist",
|
||||
"playlistId", regularPlaylistID, "songIdToAdd", songIDs[1])
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
|
||||
It("regular user can delete their own playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
})
|
||||
|
||||
// --- Admin can manage any user's playlists ---
|
||||
|
||||
It("admin can update any user's playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited",
|
||||
"songId", songIDs[0])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
plsID := resp.Playlist.Id
|
||||
|
||||
resp = doReqWithUser(adminUser, "updatePlaylist",
|
||||
"playlistId", plsID, "name", "Admin Edited")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Edited"))
|
||||
})
|
||||
|
||||
It("admin can delete any user's playlist", func() {
|
||||
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted",
|
||||
"songId", songIDs[0])
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
plsID := resp.Playlist.Id
|
||||
|
||||
resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
})
|
||||
|
||||
// --- Verify admin's playlists are unchanged ---
|
||||
|
||||
It("admin's private playlist is unchanged after failed regular user operations", func() {
|
||||
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Private"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
|
||||
It("admin's public playlist is unchanged after failed regular user operations", func() {
|
||||
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
|
||||
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlist Protection", Ordered, func() {
|
||||
var smartPlaylistID string
|
||||
var songID string
|
||||
|
||||
BeforeAll(func() {
|
||||
setupTestDB()
|
||||
|
||||
// Look up a song ID for mutation tests
|
||||
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).ToNot(BeEmpty())
|
||||
songID = songs[0].ID
|
||||
|
||||
// Insert a smart playlist directly into the DB
|
||||
smartPls := &model.Playlist{
|
||||
Name: "Smart Playlist",
|
||||
OwnerID: adminUser.ID,
|
||||
Public: false,
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
|
||||
}
|
||||
Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed())
|
||||
smartPlaylistID = smartPls.ID
|
||||
})
|
||||
|
||||
It("getPlaylist returns smart playlist with readonly flag and validUntil", func() {
|
||||
resp := doReq("getPlaylist", "id", smartPlaylistID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Playlist.Name).To(Equal("Smart Playlist"))
|
||||
Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil())
|
||||
Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue())
|
||||
expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay)
|
||||
Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second))
|
||||
})
|
||||
|
||||
It("createPlaylist rejects replacing tracks on smart playlist", func() {
|
||||
resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("updatePlaylist rejects adding songs to smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"songIdToAdd", songID)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("updatePlaylist rejects removing songs from smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"songIndexToRemove", "0")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("updatePlaylist allows renaming smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"name", "Renamed Smart")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Playlist.Name).To(Equal("Renamed Smart"))
|
||||
})
|
||||
|
||||
It("updatePlaylist allows setting comment on smart playlist", func() {
|
||||
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
|
||||
"comment", "Auto-generated playlist")
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist"))
|
||||
})
|
||||
|
||||
It("deletePlaylist can delete smart playlist", func() {
|
||||
resp := doReq("deletePlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
|
||||
resp = doReq("getPlaylist", "id", smartPlaylistID)
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ var _ = Describe("Scan Endpoints", func() {
|
||||
})
|
||||
|
||||
It("startScan requires admin user", func() {
|
||||
regularUser := createUser("user-2", "regular", "Regular User", false)
|
||||
|
||||
resp := doReqWithUser(regularUser, "startScan")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -38,7 +37,7 @@ type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
share core.Share
|
||||
playlists playlistsvc.Playlists
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
users core.User
|
||||
@@ -46,7 +45,7 @@ type Router struct {
|
||||
pluginManager PluginManager
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
@@ -122,7 +121,7 @@ func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito
|
||||
|
||||
func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
return api.playlists.NewRepository(ctx)
|
||||
return api.ds.Resource(ctx, model.Playlist{})
|
||||
}
|
||||
|
||||
r.Route("/playlist", func(r chi.Router) {
|
||||
@@ -147,26 +146,26 @@ func (api *Router) addPlaylistRoute(r chi.Router) {
|
||||
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylist(api.playlists)(w, r)
|
||||
getPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(api.playlists)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
addToPlaylist(api.playlists)(w, r)
|
||||
addToPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(api.playlists)(w, r)
|
||||
getPlaylistTrack(api.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(api.playlists)(w, r)
|
||||
reorderItem(api.ds)(w, r)
|
||||
})
|
||||
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteFromPlaylist(api.playlists)(w, r)
|
||||
deleteFromPlaylist(api.ds)(w, r)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -174,7 +173,7 @@ func (api *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
|
||||
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(api.playlists)(w, r)
|
||||
getSongPlaylists(api.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
@@ -19,14 +19,16 @@ import (
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
p := req.Params(r)
|
||||
start := p.Int64Or("_start", 0)
|
||||
return pls.TracksRepository(ctx, plsId, start == 0)
|
||||
return plsRepo.Tracks(plsId, start == 0)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
@@ -36,19 +38,21 @@ func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
accept := r.Header.Get("accept")
|
||||
if strings.ToLower(accept) == "audio/x-mpegurl" {
|
||||
handleExportPlaylist(pls)(w, r)
|
||||
handleExportPlaylist(ds)(w, r)
|
||||
return
|
||||
}
|
||||
wrapper(rest.GetAll)(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
|
||||
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
return pls.TracksRepository(ctx, plsId, true)
|
||||
return plsRepo.Tracks(plsId, true)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
@@ -58,10 +62,10 @@ func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
|
||||
return wrapper(rest.Get)
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
pl, err := pls.ImportM3U(ctx, r.Body)
|
||||
pls, err := playlists.ImportM3U(ctx, r.Body)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error parsing playlist", err)
|
||||
// TODO: consider returning StatusBadRequest for playlists that are malformed
|
||||
@@ -69,7 +73,7 @@ func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, err = w.Write([]byte(pl.ToM3U8()))
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending m3u contents", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -78,41 +82,45 @@ func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
playlist, err := pls.GetWithTracks(ctx, plsId)
|
||||
pls, err := plsRepo.GetWithTracks(plsId, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Playlist not found", "playlistId", plsId)
|
||||
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err)
|
||||
log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
|
||||
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name)
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
|
||||
_, err = w.Write([]byte(playlist.ToM3U8()))
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending playlist", "name", playlist.Name)
|
||||
log.Error(ctx, "Error sending playlist", "name", pls.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
ids, _ := p.Strings("id")
|
||||
err := pls.RemoveTracks(r.Context(), playlistId, ids)
|
||||
err := ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
return tracksRepo.Delete(ids...)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
@@ -127,7 +135,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
type addTracksPayload struct {
|
||||
Ids []string `json:"ids"`
|
||||
AlbumIds []string `json:"albumIds"`
|
||||
@@ -136,7 +144,6 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
var payload addTracksPayload
|
||||
@@ -145,23 +152,24 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
count, c := 0, 0
|
||||
if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil {
|
||||
if c, err = tracksRepo.Add(payload.Ids); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil {
|
||||
if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil {
|
||||
if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
count += c
|
||||
if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil {
|
||||
if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -175,13 +183,12 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
||||
func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
type reorderPayload struct {
|
||||
InsertBefore string `json:"insert_before"`
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
playlistId, _ := p.String(":playlistId")
|
||||
id := p.IntOr(":id", 0)
|
||||
@@ -200,8 +207,9 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = pls.ReorderTrack(ctx, playlistId, id, newPos)
|
||||
if errors.Is(err, model.ErrNotAuthorized) {
|
||||
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
||||
err = tracksRepo.Reorder(id, newPos)
|
||||
if errors.Is(err, rest.ErrPermissionDenied) {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -217,11 +225,11 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
|
||||
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
trackId, _ := p.String(":id")
|
||||
playlists, err := svc.GetPlaylists(r.Context(), trackId)
|
||||
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -41,7 +40,7 @@ type Router struct {
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists playlistsvc.Playlists
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
@@ -52,7 +51,7 @@ type Router struct {
|
||||
|
||||
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,
|
||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
@@ -291,8 +290,6 @@ func mapToSubsonicError(err error) subError {
|
||||
err = newError(responses.ErrorGeneric, err.Error())
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
err = newError(responses.ErrorDataNotFound, "data not found")
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
err = newError(responses.ErrorAuthorizationFail)
|
||||
default:
|
||||
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
|
||||
}
|
||||
|
||||
@@ -25,24 +25,32 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// mergeFormIntoQuery parses form data (both URL query params and POST body)
|
||||
// and writes all values back into r.URL.RawQuery. This is needed because
|
||||
// some Subsonic clients send parameters as form fields instead of query params.
|
||||
// This support the OpenSubsonic `formPost` extension
|
||||
func mergeFormIntoQuery(r *http.Request) error {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
var parts []string
|
||||
for key, values := range r.Form {
|
||||
for _, v := range values {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = strings.Join(parts, "&")
|
||||
return nil
|
||||
}
|
||||
|
||||
func postFormToQueryParams(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
if err := mergeFormIntoQuery(r); err != nil {
|
||||
sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
|
||||
}
|
||||
var parts []string
|
||||
for key, values := range r.Form {
|
||||
for _, v := range values {
|
||||
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
|
||||
}
|
||||
}
|
||||
r.URL.RawQuery = strings.Join(parts, "&")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -95,54 +103,64 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// authenticateRequest validates the authentication credentials in an HTTP request and returns
|
||||
// the authenticated user. It supports internal auth, reverse proxy auth, and Subsonic classic
|
||||
// auth (username + password/token/salt/jwt query params).
|
||||
//
|
||||
// Callers should handle specific error types as needed:
|
||||
// - context.Canceled: request was canceled during authentication
|
||||
// - model.ErrNotFound: username not found in database
|
||||
// - model.ErrInvalidAuth: invalid credentials (wrong password, token, etc.)
|
||||
func authenticateRequest(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Check internal auth or reverse proxy auth first
|
||||
username, _ := fromInternalOrProxyAuth(r)
|
||||
if username != "" {
|
||||
return ds.User(ctx).FindByUsername(username)
|
||||
}
|
||||
|
||||
// Fall back to Subsonic classic auth (query params)
|
||||
p := req.Params(r)
|
||||
username, _ = p.String("u")
|
||||
if username == "" {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
|
||||
pass, _ := p.String("p")
|
||||
token, _ := p.String("t")
|
||||
salt, _ := p.String("s")
|
||||
jwt, _ := p.String("jwt")
|
||||
|
||||
usr, err := ds.User(ctx).FindByUsernameWithPassword(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateCredentials(usr, pass, token, salt, jwt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return usr, nil
|
||||
}
|
||||
|
||||
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var usr *model.User
|
||||
var err error
|
||||
|
||||
username, isInternalAuth := fromInternalOrProxyAuth(r)
|
||||
if username != "" {
|
||||
authType := If(isInternalAuth, "internal", "reverse-proxy")
|
||||
usr, err = ds.User(ctx).FindByUsername(username)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
} else if err != nil {
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
} else {
|
||||
p := req.Params(r)
|
||||
username, _ := p.String("u")
|
||||
pass, _ := p.String("p")
|
||||
token, _ := p.String("t")
|
||||
salt, _ := p.String("s")
|
||||
jwt, _ := p.String("jwt")
|
||||
|
||||
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
case err != nil:
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
default:
|
||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usr, err := authenticateRequest(ds, r)
|
||||
if err != nil {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
case errors.Is(err, model.ErrNotFound), errors.Is(err, model.ErrInvalidAuth):
|
||||
log.Warn(ctx, "API: Invalid login", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
default:
|
||||
log.Error(ctx, "API: Error authenticating", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
sendError(w, r, newError(responses.ErrorAuthenticationFail))
|
||||
return
|
||||
}
|
||||
@@ -153,6 +171,19 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAuth validates Subsonic authentication from an HTTP request and returns the authenticated user.
|
||||
// Unlike the authenticate middleware, this function does not write any HTTP response, making it suitable
|
||||
// for use by external consumers (e.g., plugin endpoints) that need Subsonic auth but want to handle
|
||||
// errors themselves.
|
||||
func ValidateAuth(ds model.DataStore, r *http.Request) (*model.User, error) {
|
||||
// Parse form data into query params (same as postFormToQueryParams middleware,
|
||||
// which is not in the call chain when ValidateAuth is used directly)
|
||||
if err := mergeFormIntoQuery(r); err != nil {
|
||||
return nil, fmt.Errorf("parsing form: %w", err)
|
||||
}
|
||||
return authenticateRequest(ds, r)
|
||||
}
|
||||
|
||||
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
|
||||
valid := false
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
allPls, err := api.playlists.GetAll(ctx, model.QueryOptions{Sort: "name"})
|
||||
allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"})
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
@@ -42,7 +42,7 @@ func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
|
||||
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
|
||||
pls, err := api.playlists.GetWithTracks(ctx, id)
|
||||
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, err.Error(), "id", id)
|
||||
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
|
||||
@@ -60,6 +60,34 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
|
||||
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
owner := getUser(ctx)
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
|
||||
if playlistId != "" {
|
||||
pls, err = tx.Playlist(ctx).Get(playlistId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner.ID != pls.OwnerID {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
} else {
|
||||
pls = &model.Playlist{Name: name}
|
||||
pls.OwnerID = owner.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
return err
|
||||
})
|
||||
return playlistId, err
|
||||
}
|
||||
|
||||
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
@@ -69,7 +97,7 @@ func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error)
|
||||
if playlistId == "" && name == "" {
|
||||
return nil, errors.New("required parameter name is missing")
|
||||
}
|
||||
id, err := api.playlists.Create(ctx, playlistId, name, songIds)
|
||||
id, err := api.create(ctx, playlistId, name, songIds)
|
||||
if err != nil {
|
||||
log.Error(r, err)
|
||||
return nil, err
|
||||
@@ -83,7 +111,7 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = api.playlists.Delete(r.Context(), id)
|
||||
err = api.ds.Playlist(r.Context()).Delete(id)
|
||||
if errors.Is(err, model.ErrNotAuthorized) {
|
||||
return nil, newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ playlists.Playlists = (*fakePlaylists)(nil)
|
||||
var _ core.Playlists = (*fakePlaylists)(nil)
|
||||
|
||||
var _ = Describe("buildPlaylist", func() {
|
||||
var router *Router
|
||||
@@ -272,7 +272,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
||||
})
|
||||
|
||||
type fakePlaylists struct {
|
||||
playlists.Playlists
|
||||
core.Playlists
|
||||
lastPlaylistID string
|
||||
lastName *string
|
||||
lastComment *string
|
||||
|
||||
@@ -121,7 +121,7 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.Playlist(ctx)
|
||||
}
|
||||
db.MockedPlaylist = CreateMockPlaylistRepo()
|
||||
db.MockedPlaylist = &MockPlaylistRepo{}
|
||||
return db.MockedPlaylist
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +1,33 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
func CreateMockPlaylistRepo() *MockPlaylistRepo {
|
||||
return &MockPlaylistRepo{
|
||||
Data: make(map[string]*model.Playlist),
|
||||
PathMap: make(map[string]*model.Playlist),
|
||||
}
|
||||
}
|
||||
|
||||
type MockPlaylistRepo struct {
|
||||
model.PlaylistRepository
|
||||
Data map[string]*model.Playlist // keyed by ID
|
||||
PathMap map[string]*model.Playlist // keyed by path
|
||||
Last *model.Playlist
|
||||
Deleted []string
|
||||
Err bool
|
||||
TracksRepo *MockPlaylistTrackRepo
|
||||
|
||||
Entity *model.Playlist
|
||||
Error error
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) SetError(err bool) {
|
||||
m.Err = err
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
if m.Data != nil {
|
||||
if pls, ok := m.Data[id]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
if m.Entity == nil {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, error) {
|
||||
return m.Get(id)
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if pls.ID == "" {
|
||||
pls.ID = id.NewRandom()
|
||||
}
|
||||
m.Last = pls
|
||||
if m.Data != nil {
|
||||
m.Data[pls.ID] = pls
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if m.PathMap != nil {
|
||||
if pls, ok := m.PathMap[path]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Delete(id string) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
m.Deleted = append(m.Deleted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
|
||||
return m.TracksRepo
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Exists(id string) (bool, error) {
|
||||
if m.Err {
|
||||
return false, errors.New("error")
|
||||
}
|
||||
if m.Data != nil {
|
||||
_, found := m.Data[id]
|
||||
return found, nil
|
||||
}
|
||||
return false, nil
|
||||
return m.Entity, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
if m.Error != nil {
|
||||
return 0, m.Error
|
||||
}
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
if m.Entity == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return int64(len(m.Data)), nil
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user