Merge branch 'master' into subsonic-folder

This commit is contained in:
Patrik Wallström 2026-05-01 23:29:13 +02:00 committed by GitHub
commit 0c988edfc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
160 changed files with 9980 additions and 2913 deletions

View File

@ -164,6 +164,7 @@ RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]

View File

@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.11.1
GOLANGCI_LINT_VERSION ?= v2.12.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@ -7,11 +7,19 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
"github.com/spf13/cobra"
)
@ -20,6 +28,7 @@ var (
outputFile string
userID string
outputFormat string
syncFlag bool
)
type displayPlaylist struct {
@ -41,6 +50,15 @@ func init() {
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
plsCmd.AddCommand(exportCommand)
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
plsCmd.AddCommand(importCommand)
}
var (
@ -60,72 +78,165 @@ var (
runList(cmd.Context())
},
}
exportCommand = &cobra.Command{
Use: "export",
Short: "Export playlists to M3U files",
Long: "Export one or more Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExport(cmd.Context())
},
}
importCommand = &cobra.Command{
Use: "import [files...]",
Short: "Import M3U playlists",
Long: "Import one or more M3U files as Navidrome playlists",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runImport(cmd.Context(), args)
},
}
)
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
options := model.QueryOptions{Sort: sort}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
pls, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
return pls
}
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if errors.Is(err, model.ErrNotFound) {
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
if err != nil {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
}
}
}
if playlist == nil {
log.Fatal("Playlist not found", "name", playlistID)
log.Fatal("Playlist not found", "name", nameOrID)
}
return playlist
}
func runExporter(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
playlist := findPlaylist(ctx, ds, playlistID)
pls := playlist.ToM3U8()
if outputFile == "-" || outputFile == "" {
println(pls)
return
}
err = os.WriteFile(outputFile, []byte(pls), 0600)
err := os.WriteFile(outputFile, []byte(pls), 0600)
if err != nil {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
func runExport(ctx context.Context) {
ds, ctx := getAdminContext(ctx)
if playlistID != "" && outputFile == "" {
playlist := findPlaylist(ctx, ds, playlistID)
println(playlist.ToM3U8())
return
}
if outputFile == "" {
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
}
info, err := os.Stat(outputFile)
if err != nil || !info.IsDir() {
log.Fatal("Output path must be an existing directory", "path", outputFile)
}
if playlistID != "" {
pls := findPlaylist(ctx, ds, playlistID)
filename := str.SanitizeFilename(pls.Name) + ".m3u"
path := filepath.Join(outputFile, filename)
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
if err != nil {
log.Fatal("Error writing playlist", "file", path, err)
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
return
}
allPls := fetchPlaylists(ctx, ds, "name")
nameCounts := make(map[string]int)
for _, pls := range allPls {
nameCounts[str.SanitizeFilename(pls.Name)]++
}
exported := 0
for _, pls := range allPls {
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
if err != nil {
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
continue
}
sanitized := str.SanitizeFilename(pls.Name)
filename := sanitized + ".m3u"
if nameCounts[sanitized] > 1 {
shortID := pls.ID
if len(shortID) > 6 {
shortID = shortID[:6]
}
filename = sanitized + "_" + shortID + ".m3u"
}
path := filepath.Join(outputFile, filename)
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
if err != nil {
log.Error("Error writing playlist", "file", path, err)
continue
}
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
exported++
}
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
}
func runList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getAdminContext(ctx)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
playlists, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
allPls := fetchPlaylists(ctx, ds, "owner_name")
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
for _, playlist := range playlists {
for _, playlist := range allPls {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
display := make(displayPlaylists, len(playlists))
for idx, playlist := range playlists {
display := make(displayPlaylists, len(allPls))
for idx, playlist := range allPls {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
@ -137,3 +248,62 @@ func runList(ctx context.Context) {
fmt.Printf("%s\n", j)
}
}
func runImport(ctx context.Context, files []string) {
ds, ctx := getAdminContext(ctx)
if userID != "" {
user, err := getUser(ctx, userID, ds)
if err != nil {
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
ctx = request.WithUser(ctx, *user)
}
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
for _, file := range files {
absPath, err := filepath.Abs(file)
if err != nil {
log.Error("Error resolving path", "file", file, err)
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
continue
}
totalLines := countM3UTrackLines(absPath)
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
if err != nil {
log.Error("Error importing playlist", "file", absPath, err)
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
continue
}
matched := len(imported.Tracks)
if totalLines > 0 {
notFound := totalLines - matched
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
} else {
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
}
}
}
func countM3UTrackLines(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
count := 0
reader := ioutils.UTF8Reader(file)
for line := range slice.LinesFrom(reader) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
count++
}
return count
}

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
}
@ -219,7 +221,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@ -43,9 +44,11 @@ var allProviders = wire.NewSet(
metrics.GetPrometheusInstance,
db.Db,
plugins.GetManager,
sonic.New,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),

View File

@ -95,6 +95,7 @@ type configOptions struct {
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
UIPlaybackReportInterval time.Duration
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@ -777,6 +778,7 @@ func setViperDefaults() {
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablenowplaying", true)
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("enablesharing", false)

View File

@ -67,11 +67,12 @@ const (
ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200
DefaultUIPlaybackReportInterval = time.Minute
DefaultHttpClientTimeOut = 10 * time.Second

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type Archiver interface {
@ -87,7 +88,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@ -126,7 +127,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := sanitizeName(name)
plsName := str.SanitizeFilename(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
@ -156,11 +157,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {

View File

@ -37,10 +37,15 @@ var _ = Describe("Artwork", func() {
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
libRepo := &tests.MockLibraryRepo{}
repoRoot, _ := os.Getwd()
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
// Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB.
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
@ -80,7 +85,6 @@ var _ = Describe("Artwork", func() {
})
})
It("returns embed cover", func() {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
@ -104,7 +108,6 @@ var _ = Describe("Artwork", func() {
})
})
It("returns external cover", func() {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
@ -135,7 +138,6 @@ var _ = Describe("Artwork", func() {
})
DescribeTable("CoverArtPriority",
func(priority string, expected string) {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
conf.Server.CoverArtPriority = priority
aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
@ -197,9 +199,12 @@ var _ = Describe("Artwork", func() {
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
@ -213,13 +218,12 @@ var _ = Describe("Artwork", func() {
})
DescribeTable("ArtistArtPriority",
func(priority string, expected string) {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
conf.Server.ArtistArtPriority = priority
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(expected))
Expect(filepath.ToSlash(path)).To(HaveSuffix(expected))
},
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
@ -251,7 +255,6 @@ var _ = Describe("Artwork", func() {
})
})
It("returns embed cover", func() {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID())
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
@ -259,7 +262,6 @@ var _ = Describe("Artwork", func() {
Expect(path).To(Equal("tests/fixtures/test.mp3"))
})
It("returns embed cover if successfully extracted by ffmpeg", func() {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
Expect(err).ToNot(HaveOccurred())
r, path, err := aw.Reader(ctx)
@ -465,7 +467,10 @@ var _ = Describe("Artwork", func() {
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@ -553,7 +558,10 @@ var _ = Describe("Artwork", func() {
Name: "Only external",
FolderIDs: []string{"tmp"},
}
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}}
folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}}
rootLibRepo := &tests.MockLibraryRepo{}
rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}})
ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover})
conf.Server.CoverArtPriority = "cover.png"

View File

@ -1,9 +1,17 @@
package artwork
import (
"io/fs"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Artwork Suite")
}
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
// ReadTags is not used by albumArtworkReader, so it is left as a stub.
type osDirFS struct{ fs.FS }
func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil }
// testFileScheme is the URL scheme registered to expose a tempdir as a
// storage.MusicFS for artwork integration tests.
const testFileScheme = "testfile"
// testFileLibPath builds a `testfile://` library URL for the given absolute
// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading
// slash after ToSlash, which makes url.Parse treat the drive letter as a
// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the
// registered constructor below strips that leading slash back off.
func testFileLibPath(absPath string) string {
p := filepath.ToSlash(absPath)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return testFileScheme + "://" + p
}
func init() {
// Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by
// integration tests that need real files but not the taglib extractor.
storage.Register(testFileScheme, func(u url.URL) storage.Storage {
root := u.Path
// Undo the leading slash added by testFileLibPath on Windows so that
// os.Stat / os.DirFS receive a native path like `C:\foo`.
if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' {
root = root[1:]
}
return &osDirStorage{root: filepath.FromSlash(root)}
})
}
type osDirStorage struct{ root string }
func (s *osDirStorage) FS() (storage.MusicFS, error) {
if _, err := os.Stat(s.root); err != nil {
return nil, err
}
return osDirFS{os.DirFS(s.root)}, nil
}

View File

@ -0,0 +1,354 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
)
var _ = Describe("Album artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("an album has a single folder with cover.jpg at the album root", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← matched by cover.*
It("returns the album-root cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// Bug 2 variant: cover.* basenames tie across album-root and per-disc folders;
// compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder
// files first. Flip from PIt to It once it prefers shorter/parent paths.
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← currently wins (bug)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg
// └── cover.jpg ← should win (album-root fallback)
PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
"Artist/Album/CD1/cover.jpg": imageFile("disc1"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(al.FolderIDs).To(HaveLen(2),
"sanity check: scanner should treat the two disc subfolders as one multi-disc album")
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// Bug 2: folder.jpg basenames tie across album-root and per-disc folders;
// the lexicographic full-path tiebreaker in compareImageFiles ranks
// "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg".
// Flip from PIt to It once compareImageFiles prefers shorter/parent paths.
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg ← currently wins (bug)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── folder.jpg
// └── folder.jpg ← should win (album-root fallback)
PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
"Artist/Album/folder.jpg": imageFile("album-root"),
"Artist/Album/CD1/folder.jpg": imageFile("disc1"),
"Artist/Album/CD2/folder.jpg": imageFile("disc2"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
// Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder
// lookup whenever an album lives entirely under a single subfolder, so an
// album-root cover is never considered. Flip from PIt to It once the guard
// accepts single-folder albums whose parent isn't already in the folder set.
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ └── 01 - Track.mp3
// └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug)
PIt("uses the parent-folder cover (currently ignored — bug)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
// └── cover.jpg
It("returns the embedded image", func() {
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
"Artist/Album/cover.jpg": imageFile("external"),
})
scan()
// Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream.
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
When("CoverArtPriority lists external first but no external file is present", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
It("falls through to embedded artwork", func() {
conf.Server.CoverArtPriority = "external, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
})
scan()
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes))
})
})
When("the only cover file uses uppercase extension and a different case in its name", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── Cover.JPG ← matched case-insensitively by cover.*
It("matches case-insensitively against cover.*", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/Cover.JPG": imageFile("case-insensitive"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive")))
})
})
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (no numeric suffix)
// └── cover.1.jpg
It("prefers the file without a numeric suffix", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("secondary"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no image files — returns ErrUnavailable)
It("returns ErrUnavailable", func() {
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
al := firstAlbum()
_, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt))
Expect(err).To(HaveOccurred())
})
})
// Doc scenarios from:
// https://www.navidrome.org/docs/usage/library/artwork/#albums
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
When("only folder.jpg is present (cover.* and front.* missing)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── folder.jpg ← matched by folder.*
It("falls through to folder.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": imageFile("folder"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
When("only front.jpg is present (cover.* and folder.* missing)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── front.jpg ← matched by front.*
It("falls through to front.jpg", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front")))
})
})
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (cover.* is first in priority)
// ├── folder.jpg
// └── front.jpg
It("prefers cover.* (first in CoverArtPriority)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
When("only folder.* and front.* exist (priority order check)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── folder.jpg ← wins (folder.* comes before front.*)
// └── front.jpg
It("prefers folder.* over front.*", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/folder.jpg": imageFile("folder"),
"Artist/Album/front.jpg": imageFile("front"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder")))
})
})
When("three cover files tie by basename and differ only by numeric suffix", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── cover.jpg ← wins (no numeric suffix)
// ├── cover.1.jpg
// └── cover.2.jpg
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.2.jpg": imageFile("second"),
"Artist/Album/cover.jpg": imageFile("primary"),
"Artist/Album/cover.1.jpg": imageFile("first"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary")))
})
})
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
It("skips the unknown pattern and falls through to the matching one", func() {
conf.Server.CoverArtPriority = "bogus.*, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (no embedded picture)
// └── cover.jpg ← wins (embedded skipped, falls through)
It("falls through to the next priority entry", func() {
conf.Server.CoverArtPriority = "embedded, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover")))
})
})
})

View File

@ -0,0 +1,167 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#artists
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
var _ = Describe("Artist artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("the artist folder contains an artist.jpg", func() {
// Artist/
// ├── artist.jpg ← matched by artist.*
// └── Album/
// └── 01 - Track.mp3
It("returns the artist.* image from the artist folder", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("artist.* only exists inside an album folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg ← matched by album/artist.*
It("falls through to album/artist.* and returns that image", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
When("both the artist folder and an album folder have an artist.* image", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder")))
})
})
When("an artist has an uploaded image and a matching artist.* file", func() {
// <DataFolder>/
// └── artwork/
// └── artist/
// └── <id>_upload.jpg ← wins (uploaded image beats the priority chain)
// Library:
// Artist/
// ├── artist.jpg (ignored — uploaded image comes first)
// └── Album/
// └── 01 - Track.mp3
It("prefers the uploaded image over any priority-chain match", func() {
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/artist.jpg": imageFile("artist-folder"),
})
scan()
ar := soleArtist()
uploaded := ar.ID + "_upload.jpg"
writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded"))
ar.UploadedImage = uploaded
Expect(ds.Artist(ctx).Put(&ar)).To(Succeed())
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded")))
})
})
When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── artist.jpg ← matched by album/artist.*
It("resolves the pattern against the artist's album image files", func() {
conf.Server.ArtistArtPriority = "album/artist.*, external"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
"Artist/Album/artist.jpg": imageFile("album-artist"),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist")))
})
})
When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
// <ArtistImageFolder>/
// └── Artist.jpg ← matched by artist name (image-folder source)
// Library:
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no artist.* present in library)
It("returns the image from the configured artist image folder", func() {
imgFolder := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed())
conf.Server.ArtistImageFolder = imgFolder
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
})
scan()
ar := soleArtist()
artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder")))
})
})
})
func soleArtist() model.Artist {
GinkgoHelper()
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"artist.name": "Artist"},
})
Expect(err).ToNot(HaveOccurred())
if len(artists) == 0 {
Fail("sole artist not found")
return model.Artist{}
}
return artists[0]
}

View File

@ -0,0 +1,276 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Disc artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("the album is single-disc with a disc1.jpg in the only folder", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── disc1.jpg ← matched by disc*.*
It("returns the disc1.jpg image (matched as disc*.*)", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": imageFile("disc1-image"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image")))
})
})
When("the album has no per-disc image and no album cover", func() {
// Artist/
// └── Album/
// └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable)
It("returns ErrUnavailable for the disc lookup", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = "cover.*, folder.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
_, err := readArtworkOrErr(discID)
Expect(err).To(HaveOccurred())
})
})
When("the album has no per-disc image but has an album cover", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// └── cover.jpg ← album-level fallback (no disc art present)
It("falls back to the album cover", func() {
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")))
})
})
When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3
// ├── disc1.jpg ← matches request for disc 1
// └── disc10.jpg
It("matches the requested disc number, not a higher-numbered one", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/disc1.jpg": imageFile("disc-one"),
"Artist/Album/disc10.jpg": imageFile("disc-ten"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one")))
})
})
When("a multi-disc album has per-disc covers", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg ← matches request for disc 1
// └── CD2/
// ├── 01 - Track.mp3
// └── disc2.jpg ← matches request for disc 2
It("returns the requested disc's image", func() {
conf.Server.DiscArtPriority = "disc*.*"
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2")))
})
})
// Doc scenarios from:
// https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
// Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
When("a disc subfolder has a cd2.png image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg
// └── CD2/
// ├── 01 - Track.mp3
// └── cd2.png ← matched by cd*.* for disc 2
It("matches via the cd*.* pattern", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2")))
})
})
When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── cover.jpg ← matched by cover.* inside disc folder
// └── CD2/
// ├── 01 - Track.mp3
// └── cover.jpg
It("falls through to cover.* inside the disc folder", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"),
"Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover")))
})
})
When("DiscArtPriority is the empty string", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg (ignored — DiscArtPriority is empty)
// ├── CD2/
// │ ├── 01 - Track.mp3
// │ └── cd2.png (ignored — DiscArtPriority is empty)
// └── cover.jpg ← used for every disc (album-level fallback)
It("skips every disc-level source and returns the album cover", func() {
conf.Server.DiscArtPriority = ""
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
al := firstAlbum()
for _, n := range []int{1, 2} {
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")),
"disc %d should use the album cover when DiscArtPriority is empty", n)
}
})
})
When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
// Artist/
// └── Album/
// ├── disc1/
// │ ├── disc1.jpg ← matched by disc*.* for disc 1
// │ ├── 01 - Track.mp3
// │ └── 02 - Track.mp3
// ├── disc2/
// │ ├── cd2.png ← matched by cd*.* for disc 2
// │ ├── 01 - Track.mp3
// │ └── 02 - Track.mp3
// └── cover.jpg (album-level fallback, unused here)
It("matches the per-disc image for each disc", func() {
conf.Server.DiscArtPriority = defaultDiscPriority
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
"Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}),
"Artist/Album/disc1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/disc2/cd2.png": imageFile("cd-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
al := firstAlbum()
disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt)
Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1")))
Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2")))
})
})
When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
It("selects the subtitle-named image", func() {
conf.Server.DiscArtPriority = "discsubtitle"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks")))
})
})
When("discsubtitle is set but no image filename matches the subtitle", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
It("falls through to the next priority entry", func() {
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
"Artist/Album/cover.jpg": imageFile("cover"),
})
scan()
al := firstAlbum()
discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt)
Expect(readArtwork(discID)).To(Equal(imageBytes("cover")))
})
})
})

View File

@ -0,0 +1,184 @@
package artworke2e_test
import (
"bytes"
"context"
_ "embed"
"errors"
"hash/fnv"
"image"
"image/color"
"image/png"
"io"
"maps"
"net/url"
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.senan.xyz/taglib"
)
// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that
// contains a valid MP3 stream with an embedded picture. Used in the
// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't
// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the
// scanner still populates EmbedArtPath via the JSON-tagged track, and the
// artwork reader gets real bytes when it calls libFS.Open.
//
//go:embed testdata/embedded_art.mp3
var realMP3WithEmbeddedArt []byte
// embeddedArtBytes is the exact image payload that the artwork reader will
// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can
// assert byte-for-byte equality — if this ever differs it means the reader
// pulled from a different source.
var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt)
func extractEmbeddedArt(mp3 []byte) []byte {
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
if err != nil {
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
}
defer tf.Close()
images := tf.Properties().Images
if len(images) == 0 {
panic("embedded-art fixture has no embedded images")
}
data, err := tf.Image(0)
if err != nil || len(data) == 0 {
panic("embedded-art fixture: could not read image 0")
}
return data
}
// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative
// path so libFS.Open returns an MP3 stream taglib can parse.
func replaceWithRealMP3(relPath string) {
GinkgoHelper()
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt}
}
// placeholderBytes returns the bundled album-placeholder image bytes — the
// same stream the artwork reader emits when every source falls through.
func placeholderBytes() []byte {
GinkgoHelper()
r, err := resources.FS().Open(consts.PlaceholderAlbumArt)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
data, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return data
}
// writeUploadedImage drops `filename` into <DataFolder>/artwork/<entity>/ with
// the given bytes, matching the on-disk layout expected by
// model.UploadedImagePath.
func writeUploadedImage(entity, filename string, data []byte) {
GinkgoHelper()
dir := filepath.Dir(model.UploadedImagePath(entity, filename))
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed())
}
func newNoopFFmpeg() *tests.MockFFmpeg {
ff := tests.NewMockFFmpeg("")
ff.Error = errors.New("noop")
return ff
}
// trackFile builds a FakeFS MP3 entry with optional tag overrides.
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
tags := storagetest.Track(num, title)
for _, e := range extra {
maps.Copy(tags, e)
}
return storagetest.MP3(tags)
}
// imageFile builds a label-keyed image entry. The bytes are deterministic
// per-label so tests can assert which file won.
func imageFile(label string) *fstest.MapFile {
return &fstest.MapFile{Data: []byte("image:" + label)}
}
// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by
// tests that feed the bytes into image.Decode (e.g. playlist tiled covers).
func realPNG(label string) *fstest.MapFile {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
// Derive a deterministic color per label.
h := fnv.New32a()
_, _ = h.Write([]byte(label))
sum := h.Sum32()
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
for y := range 2 {
for x := range 2 {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
Expect(png.Encode(&buf, img)).To(Succeed())
return &fstest.MapFile{Data: buf.Bytes()}
}
// imageBytes returns the bytes that imageFile(label) writes.
func imageBytes(label string) []byte { return imageFile(label).Data }
// setLayout populates fakeFS with the given map. Call after setupHarness.
// All paths must be forward-slash and relative (no leading "/").
func setLayout(files fstest.MapFS) {
GinkgoHelper()
fakeFS.SetFiles(files)
}
func readArtwork(artID model.ArtworkID) []byte {
GinkgoHelper()
r, _, err := aw.Get(ctx, artID, 0, false)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return b
}
func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
r, _, err := aw.Get(ctx, artID, 0, false)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// noopProvider implements external.Provider with not-found returns so the
// "external" priority entry never produces a result.
type noopProvider struct{}
func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
var _ external.Provider = (*noopProvider)(nil)

View File

@ -0,0 +1,110 @@
package artworke2e_test
import (
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Doc reference:
// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles
// Navidrome resolves mediafile artwork in this order:
// 1. Embedded image from the mediafile itself
// 2. For multi-disc albums, disc-level artwork
// 3. Album cover art
//
// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1)
// is covered by the existing embedded-art album tests (which currently
// Skip under FakeFS). The tests below cover (2) and (3): the fallback
// chain for tracks without embedded art.
var _ = Describe("MediaFile artwork fallback", func() {
BeforeEach(func() {
setupHarness()
})
When("a multi-disc album track has no embedded art", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ ├── 01 - Track.mp3
// │ └── disc1.jpg
// ├── CD2/
// │ ├── 01 - Track.mp3 ← track requested
// │ └── disc2.jpg ← wins (disc-level before album-level)
// └── cover.jpg
It("falls back to the disc-level artwork (not the album cover)", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/CD1/disc1.jpg": imageFile("disc-1"),
"Artist/Album/CD2/disc2.jpg": imageFile("disc-2"),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2")))
})
})
When("a single-disc album track has no embedded art", func() {
// Artist/
// └── Album/
// ├── 01 - Track.mp3 ← track requested
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
It("falls back to the album cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
"Artist/Album/cover.jpg": imageFile("album-cover"),
})
scan()
mf := mediafileOn("Artist/Album/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover")))
})
})
When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() {
// Artist/
// └── Album/
// ├── CD1/
// │ └── 01 - Track.mp3
// ├── CD2/
// │ └── 01 - Track.mp3 ← track requested
// └── cover.jpg ← wins (no disc image → album-level fallback)
It("falls through from disc to album cover", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
conf.Server.DiscArtPriority = defaultDiscPriority
setLayout(fstest.MapFS{
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
"Artist/Album/cover.jpg": imageFile("album-root"),
})
scan()
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root")))
})
})
})
func mediafileOn(relPath string) model.MediaFile {
GinkgoHelper()
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Like{"media_file.path": relPath},
})
Expect(err).ToNot(HaveOccurred())
if len(mfs) == 0 {
Fail("mediafile not found: " + relPath)
return model.MediaFile{}
}
return mfs[0]
}

View File

@ -0,0 +1,158 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Playlist artwork resolves in this priority order:
// 1. Uploaded image (<DataFolder>/artwork/playlist/<file>)
// 2. Sidecar image next to the .m3u file (same basename, any image ext)
// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed)
// 4. Generated 2x2 tiled cover from the playlist's albums
// 5. Album placeholder image
//
// The library FS is FakeFS, but uploaded/sidecar/local-external images are
// real files on disk — the reader reads them via os.Open, so the tests
// place them in a real tempdir under DataFolder.
var _ = Describe("Playlist artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("a playlist has an uploaded image", func() {
// <DataFolder>/
// └── artwork/
// └── playlist/
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload"))
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload")))
})
})
When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
// <tempdir>/
// ├── MyList.m3u
// └── MyList.jpg ← matched by sidecar (same basename, case-insensitive)
It("returns the sidecar image", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar")))
})
})
When("a playlist's sidecar uses a different extension case", func() {
// <tempdir>/
// ├── MyList.m3u
// └── MyList.PNG ← matched case-insensitively
It("matches case-insensitively", func() {
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png")))
})
})
When("a playlist has an ExternalImageURL pointing to a local file", func() {
// <tempdir>/
// └── cover.jpg ← absolute path stored in ExternalImageURL
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")
Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local")))
})
})
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
// (no local files — http source is gated off, reader falls through to placeholder)
It("skips the URL and falls through to the bundled placeholder", func() {
conf.Server.EnableM3UExternalAlbumArt = false
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
})
})
When("a playlist has no images and no tracks", func() {
// (reader falls all the way through to the bundled album placeholder)
It("returns the album placeholder", func() {
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
})
})
When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
// Library:
// Artist/
// ├── AlbumA/
// │ ├── 01 - Track.mp3
// │ └── cover.png (real PNG — wins as tile 1 source)
// └── AlbumB/
// ├── 01 - Track.mp3
// └── cover.png (real PNG — wins as tile 2 source)
// Playlist "pl-7" references tracks from both albums, so the reader
// generates a 2x2 tiled cover from 2 distinct album art tiles (the
// tiled generator mirrors when it has fewer than 4 unique tiles).
It("generates a tiled cover from album art", func() {
conf.Server.CoverArtPriority = "cover.*"
setLayout(fstest.MapFS{
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
"Artist/AlbumA/cover.png": realPNG("albumA"),
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
"Artist/AlbumB/cover.png": realPNG("albumB"),
})
scan()
// Pull the scanned mediafile IDs so we can attach them to the playlist.
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(2))
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
data := readArtwork(pl.CoverArtID())
// The tiled cover is a PNG-encoded 600x600 image (tileSize const).
// Exact bytes vary (random album order), so assert format + non-trivial size.
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
Expect(len(data)).To(BeNumerically(">", 1000))
})
})
})
func putPlaylist(pl model.Playlist) model.Playlist {
GinkgoHelper()
if pl.OwnerID == "" {
pl.OwnerID = "admin-1"
}
Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed())
return pl
}

View File

@ -0,0 +1,42 @@
package artworke2e_test
import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Radio artwork resolution", func() {
BeforeEach(func() {
setupHarness()
})
When("a radio has an uploaded image", func() {
// <DataFolder>/
// └── artwork/
// └── radio/
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
It("returns the uploaded image bytes", func() {
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo"))
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo")))
})
})
When("a radio has no uploaded image", func() {
// (no files on disk — reader has no sources to fall back to)
It("returns ErrUnavailable", func() {
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
Expect(ds.Radio(ctx).Put(&rd)).To(Succeed())
artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil)
_, err := readArtworkOrErr(artID)
Expect(err).To(HaveOccurred())
})
})
})

View File

@ -0,0 +1,106 @@
package artworke2e_test
import (
"context"
"path/filepath"
"testing"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
"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"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestArtworkE2E(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Artwork E2E Suite")
}
const fakeLibScheme = "artworkfake"
const fakeLibPath = fakeLibScheme + ":///music"
var (
ctx context.Context
ds *tests.MockDataStore
aw artwork.Artwork
fakeFS *storagetest.FakeFS
)
// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps
// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup
// can't unlink a file with a live handle on Windows. A suite-level tempdir
// combined with an AfterSuite close avoids the lock conflict.
var suiteDBTempDir string
var _ = BeforeSuite(func() {
suiteDBTempDir = GinkgoT().TempDir()
})
var _ = AfterSuite(func() {
db.Close(GinkgoT().Context())
})
func setupHarness() {
DeferCleanup(configtest.SetupConfig())
tempDir := GinkgoT().TempDir()
// Reuse the suite-level DB path so the singleton connection keeps working
// across specs (see suiteDBTempDir comment).
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL"
conf.Server.DataFolder = tempDir
conf.Server.MusicFolder = fakeLibPath
conf.Server.DevExternalScanner = false
conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call
conf.Server.EnableExternalServices = false
db.Db().SetMaxOpenConns(1)
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
db.Init(ctx)
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
fakeFS = &storagetest.FakeFS{}
storagetest.Register(fakeLibScheme, fakeFS)
aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{})
}
func scan() {
GinkgoHelper()
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
_, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
}
func firstAlbum() model.Album {
GinkgoHelper()
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
return albums[0]
}

Binary file not shown.

View File

@ -0,0 +1,44 @@
package artwork
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/model"
)
// libraryView bundles the MusicFS for a library with its absolute root path,
// so readers can open library-relative paths through FS and compose absolute
// paths (for ffmpeg, which is path-based) via Abs.
type libraryView struct {
FS storage.MusicFS
absRoot string
}
// Abs returns the absolute path for a library-relative path. Returns "" for an
// empty rel so callers (fromFFmpegTag) can treat it as "no path available".
func (v libraryView) Abs(rel string) string {
if rel == "" {
return ""
}
return filepath.Join(v.absRoot, rel)
}
// loadLibraryView resolves the MusicFS and absolute root path in a single
// library lookup.
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
lib, err := ds.Library(ctx).Get(libID)
if err != nil {
return libraryView{}, err
}
s, err := storage.For(lib.Path)
if err != nil {
return libraryView{}, err
}
fs, err := s.FS()
if err != nil {
return libraryView{}, err
}
return libraryView{FS: fs, absRoot: lib.Path}, nil
}

View File

@ -0,0 +1,45 @@
package artwork
import (
"context"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("loadLibraryView", Ordered, func() {
var ctx context.Context
var ds *tests.MockDataStore
BeforeAll(func() {
storagetest.Register("fake", &storagetest.FakeFS{})
})
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}}
})
It("returns a view for a library backed by registered storage", func() {
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
lib, err := loadLibraryView(ctx, ds, 1)
Expect(err).ToNot(HaveOccurred())
Expect(lib.FS).ToNot(BeNil())
Expect(lib.absRoot).To(Equal("fake:///music"))
})
It("returns an error when the library does not exist", func() {
_, err := loadLibraryView(ctx, ds, 999)
Expect(err).To(HaveOccurred())
})
It("returns an error when the library path uses an unregistered scheme", func() {
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
_, err := loadLibraryView(ctx, ds, 2)
Expect(err).To(HaveOccurred())
})
})

View File

@ -7,14 +7,13 @@ import (
"errors"
"fmt"
"io"
"path/filepath"
"path"
"slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
@ -24,12 +23,12 @@ import (
type albumArtworkReader struct {
cacheKey
a *artwork
provider external.Provider
album model.Album
updatedAt *time.Time
imgFiles []string
rootFolder string
a *artwork
provider external.Provider
album model.Album
updatedAt *time.Time
imgFiles []string // library-relative, forward-slash, no leading slash
lib libraryView
}
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
@ -41,13 +40,17 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID)
if err != nil {
return nil, err
}
a := &albumArtworkReader{
a: artwork,
provider: provider,
album: *al,
updatedAt: imagesUpdateAt,
imgFiles: imgFiles,
rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""),
a: artwork,
provider: provider,
album: *al,
updatedAt: imagesUpdateAt,
imgFiles: imgFiles,
lib: lib,
}
a.cacheKey.artID = artID
if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) {
@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
embedRel := a.album.EmbedArtPath
ff = append(ff,
fromTag(ctx, a.lib.FS, embedRel),
fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)),
)
case pattern == "external":
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
case len(a.imgFiles) > 0:
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern))
}
}
return ff
@ -132,13 +138,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
var imgFiles []string
var updatedAt time.Time
for _, f := range folders {
path := f.AbsolutePath()
paths = append(paths, path)
paths = append(paths, f.AbsolutePath())
if f.ImagesUpdatedAt.After(updatedAt) {
updatedAt = f.ImagesUpdatedAt
}
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
for _, img := range f.ImageFiles {
imgFiles = append(imgFiles, filepath.Join(path, img))
imgFiles = append(imgFiles, path.Join(rel, img))
}
}
@ -179,8 +185,8 @@ func compareImageFiles(a, b string) int {
b = strings.ToLower(b)
// Extract base filenames without extensions
baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a))
baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b))
baseA := strings.TrimSuffix(path.Base(a), path.Ext(a))
baseB := strings.TrimSuffix(path.Base(b), path.Ext(b))
// Compare base names first, then full paths if equal
return cmp.Or(

View File

@ -3,7 +3,6 @@ package artwork
import (
"context"
"errors"
"path/filepath"
"time"
"github.com/navidrome/navidrome/model"
@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() {
// Files should be sorted by base filename without extension, then by full path
// "back" < "cover", so back.jpg comes first
// Then all cover.jpg files, sorted by path
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg"))
Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg"))
Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg"))
Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg"))
})
It("prioritizes files without numeric suffixes", func() {
@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(imgFiles).To(HaveLen(3))
// cover.jpg should come first because "cover" < "cover.1" < "cover.2"
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg"))
Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg"))
})
It("handles case-insensitive sorting", func() {
@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(imgFiles).To(HaveLen(3))
// Files should be sorted case-insensitively: BACK, cover, Folder
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg"))
})
It("includes images from parent folder for multi-disc albums", func() {
@ -151,8 +150,8 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
Expect(imgFiles).To(HaveLen(2))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg"))
Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg"))
})
It("does not query parent when parent ID is already in album folders", func() {
@ -179,7 +178,7 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
// Get should not have been called (parent already in folder set)
Expect(repo.getCallCount).To(Equal(0))
})
@ -209,7 +208,7 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg"))
// Get should not have been called (different parents)
Expect(repo.getCallCount).To(Equal(0))
})
@ -232,7 +231,7 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg"))
// Get should not have been called (single folder, no parent lookup)
Expect(repo.getCallCount).To(Equal(0))
})
@ -290,7 +289,7 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(HaveLen(1))
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg")))
Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg"))
Expect(repo.getCallCount).To(Equal(1))
})
})

View File

@ -7,6 +7,7 @@ import (
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
@ -35,6 +36,7 @@ type artistReader struct {
artistFolder string
imgFiles []string
imgFolderImgPath string // cached path from ArtistImageFolder lookup
lib libraryView
}
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
if err != nil {
return nil, err
}
var lib libraryView
if len(als) > 0 {
lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID)
if err != nil {
return nil, err
}
}
a := &artistReader{
a: artwork,
provider: provider,
artist: *ar,
artistFolder: artistFolder,
imgFiles: imgFiles,
lib: lib,
}
// TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can
// change _after_ retrieving from external sources, making the key invalid
@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
case pattern == "image-folder":
ff = append(ff, a.fromArtistImageFolder(ctx))
case strings.HasPrefix(pattern, "album/"):
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
if a.lib.FS != nil {
ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
}
default:
ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern))
ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern))
}
}
return ff
}
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
// fromArtistFolder walks up from artistFolder toward libPath looking for a
// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth
// and the library root: once we reach libPath (or if artistFolder is outside
// libPath), the walk stops. All reads go through libFS, which keeps artwork
// resolution scoped to the configured library.
func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if libFS == nil {
return nil, "", fmt.Errorf("artist folder lookup unavailable")
}
rel, err := filepath.Rel(libPath, artistFolder)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
}
// fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may
// return backslash separators on Windows.
rel = filepath.ToSlash(rel)
current := artistFolder
for range maxArtistFolderTraversalDepth {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern)
if err == nil {
return reader, hit, nil
}
parent := filepath.Dir(current)
if parent == current {
break
if rel == "." {
break // reached library root; don't traverse above it
}
current = parent
rel = path.Dir(rel)
current = filepath.Dir(current)
}
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder)
}
}
func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
fsys := os.DirFS(folder)
matches, err := fs.Glob(fsys, pattern)
// findImageInFolder globs libFS at relFolder for pattern and returns the first
// matching image. absFolder is used only for the returned display path and log
// messages so callers see absolute-looking paths consistent with the rest of
// the artwork pipeline.
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder)
globPattern := pattern
if relFolder != "." {
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
}
matches, err := fs.Glob(libFS, globPattern)
if err != nil {
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
return nil, "", err
}
@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
// suffixes (e.g., artist.jpg before artist.1.jpg)
slices.SortFunc(imagePaths, compareImageFiles)
// Try to open files in sorted order
for _, p := range imagePaths {
filePath := filepath.Join(folder, p)
f, err := os.Open(filePath)
f, err := libFS.Open(p)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
log.Warn(ctx, "Could not open cover art file", "file", p, err)
continue
}
return f, filePath, nil
_, name := path.Split(p)
return f, filepath.Join(absFolder, name), nil
}
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder)
}
func escapeGlobLiteral(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '\\', '*', '?', '[', ']':
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"time"
@ -12,7 +13,6 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -62,13 +62,12 @@ var _ = Describe("artistArtworkReader", func() {
When("artist has only one album", func() {
It("returns the parent folder", func() {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
paths = []string{
filepath.FromSlash("/music/artist/album1"),
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
Expect(folder).To(Equal("/music/artist"))
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
@ -88,14 +87,13 @@ var _ = Describe("artistArtworkReader", func() {
When("the album paths contain same prefix", func() {
It("returns the common prefix", func() {
tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)")
paths = []string{
filepath.FromSlash("/music/artist/album1"),
filepath.FromSlash("/music/artist/album2"),
}
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
Expect(err).ToNot(HaveOccurred())
Expect(folder).To(Equal("/music/artist"))
Expect(folder).To(Equal(filepath.FromSlash("/music/artist")))
Expect(upd).To(Equal(expectedUpdTime))
})
})
@ -120,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() {
var (
ctx context.Context
tempDir string
libFS fs.FS
testFunc sourceFunc
)
BeforeEach(func() {
ctx = context.Background()
tempDir = GinkgoT().TempDir()
libFS = os.DirFS(tempDir)
})
When("artist folder contains matching image", func() {
@ -137,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() {
artistImagePath := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds and returns the image", func() {
@ -154,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() {
})
})
When("artist folder name contains glob metacharacters", func() {
BeforeEach(func() {
artistDir := filepath.Join(tempDir, "Artist [Live]")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
artistImagePath := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("treats the folder path literally when globbing through the library fs", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg"))
data, err := io.ReadAll(reader)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("bracketed artist image"))
reader.Close()
})
})
When("artist folder is empty but parent contains image", func() {
BeforeEach(func() {
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
@ -166,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() {
artistImagePath := filepath.Join(parentDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds image in parent directory", func() {
@ -194,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() {
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds image in grandparent directory", func() {
@ -223,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("prioritizes the closest (artist folder) image", func() {
@ -249,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("returns the first valid image file in sorted order", func() {
@ -276,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
@ -304,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "*.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*")
})
It("sorts case-insensitively", func() {
@ -330,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() {
// Create non-matching files
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("returns an error", func() {
@ -349,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() {
artistDir := filepath.Join(tempDir, "artist")
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("handles root boundary gracefully", func() {
@ -370,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() {
restrictedFile := filepath.Join(artistDir, "artist.jpg")
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("logs warning and continues searching", func() {
@ -400,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
// The fromArtistFolder is called with the artist folder path
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*")
})
It("finds artist.jpg in artist folder for single album artist", func() {

View File

@ -5,7 +5,7 @@ import (
"crypto/md5"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@ -13,7 +13,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -24,10 +23,11 @@ type discArtworkReader struct {
a *artwork
album model.Album
discNumber int
imgFiles []string
discFolders map[string]bool
imgFiles []string // library-relative, forward-slash, no leading slash
discFoldersRel map[string]bool // library-relative folder paths
isMultiFolder bool
firstTrackPath string
firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs
lib libraryView
updatedAt *time.Time
}
@ -57,18 +57,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
return nil, err
}
// Build disc folder set and find first track
discFolders := make(map[string]bool)
var firstTrackPath string
lib, err := loadLibraryView(ctx, a.ds, al.LibraryID)
if err != nil {
return nil, err
}
// Build disc folder set and find first track. mf.Path is already library-relative.
var firstTrackRel string
allFolderIDs := make(map[string]bool)
for _, mf := range mfs {
allFolderIDs[mf.FolderID] = true
if firstTrackPath == "" {
firstTrackPath = mf.Path
if firstTrackRel == "" {
firstTrackRel = filepath.ToSlash(mf.Path)
}
}
// Resolve folder IDs to absolute paths
// Resolve folder IDs to library-relative paths
discFoldersRel := make(map[string]bool)
if len(allFolderIDs) > 0 {
folderIDs := make([]string, 0, len(allFolderIDs))
for id := range allFolderIDs {
@ -81,7 +86,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
return nil, err
}
for _, f := range folders {
discFolders[f.AbsolutePath()] = true
rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/")
discFoldersRel[rel] = true
}
}
@ -92,9 +98,10 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID
album: *al,
discNumber: discNumber,
imgFiles: imgFiles,
discFolders: discFolders,
discFoldersRel: discFoldersRel,
isMultiFolder: isMultiFolder,
firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath),
firstTrackRel: firstTrackRel,
lib: lib,
updatedAt: imagesUpdatedAt,
}
r.cacheKey.artID = artID
@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath))
ff = append(ff,
fromTag(ctx, d.lib.FS, d.firstTrackRel),
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
)
case pattern == "external":
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp
func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
stem := strings.TrimSuffix(name, filepath.Ext(name))
name := path.Base(file)
stem := strings.TrimSuffix(name, path.Ext(name))
if !strings.EqualFold(stem, subtitle) {
continue
}
f, err := os.Open(file)
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
@ -214,8 +224,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
return func() (io.ReadCloser, string, error) {
var fallbacks []string
for _, file := range d.imgFiles {
_, name := filepath.Split(file)
name = strings.ToLower(name)
name := strings.ToLower(path.Base(file))
match, err := filepath.Match(pattern, name)
if err != nil {
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
@ -230,7 +239,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
if num != d.discNumber {
continue
}
f, err := os.Open(file)
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue
@ -239,14 +248,14 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
}
}
if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] {
if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] {
continue
}
fallbacks = append(fallbacks, file)
}
for _, file := range fallbacks {
f, err := os.Open(file)
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
continue

View File

@ -74,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() {
tmpDir = GinkgoT().TempDir()
})
createFile := func(path string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
// createFile creates the file on disk and returns its library-relative forward-slash path.
createFile := func(relPath string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return fullPath
return relPath
}
// removeFile removes a library-relative file from disk.
removeFile := func(relPath string) {
Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed())
}
It("matches file with disc number in single-folder album", func() {
f1 := createFile("album/disc1.jpg")
f2 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -101,9 +108,10 @@ var _ = Describe("Disc Artwork Reader", func() {
It("matches file without number in single-folder album (shared disc art)", func() {
f1 := createFile("album/cover.png")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.*")
@ -118,9 +126,10 @@ var _ = Describe("Disc Artwork Reader", func() {
f1 := createFile("album/shellac.png")
makeReader := func(discNum int) *discArtworkReader {
return &discArtworkReader{
discNumber: discNum,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: discNum,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
}
@ -139,9 +148,10 @@ var _ = Describe("Disc Artwork Reader", func() {
f2 := createFile("album/disc1.jpg")
f3 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1, f2, f3},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 2,
imgFiles: []string{f1, f2, f3},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -163,9 +173,10 @@ var _ = Describe("Disc Artwork Reader", func() {
f1 := createFile("album/cover.png")
f2 := createFile("album/disc1.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
@ -191,9 +202,10 @@ var _ = Describe("Disc Artwork Reader", func() {
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: discNumber,
imgFiles: files,
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -210,12 +222,13 @@ var _ = Describe("Disc Artwork Reader", func() {
It("tries the next fallback candidate when the first one cannot be opened", func() {
f1 := createFile("album/cover.jpg")
f2 := createFile("album/cover.png")
// Remove f1 so os.Open will fail on it; f2 should still win.
Expect(os.Remove(f1)).To(Succeed())
// Remove f1 so Open will fail on it; f2 should still win.
removeFile(f1)
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.*")
@ -234,15 +247,16 @@ var _ = Describe("Disc Artwork Reader", func() {
// that first file is unreadable.
f1 := createFile("album/stale/cover.png")
f2 := createFile("album/cover.png")
Expect(os.Remove(f1)).To(Succeed())
removeFile(f1)
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{
filepath.Join(tmpDir, "album"): true,
filepath.Join(tmpDir, "album/stale"): true,
discFoldersRel: map[string]bool{
"album": true,
"album/stale": true,
},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.png")
@ -260,9 +274,10 @@ var _ = Describe("Disc Artwork Reader", func() {
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: discNumber,
imgFiles: files,
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, pattern)
@ -282,10 +297,11 @@ var _ = Describe("Disc Artwork Reader", func() {
f1 := createFile("album/cd1/disc.jpg")
f2 := createFile("album/cd2/disc.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
isMultiFolder: true,
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album/cd1": true},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -300,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", func() {
// disc2.jpg in cd1 folder should match disc 2, not disc 1
f1 := createFile("album/cd1/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
isMultiFolder: true,
discNumber: 2,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album/cd1": true},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -317,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() {
It("does not match disc2.jpg when looking for disc 1", func() {
f1 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
discNumber: 1,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
@ -339,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() {
tmpDir = GinkgoT().TempDir()
})
createFile := func(path string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
createFile := func(relPath string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return fullPath
return relPath
}
It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
@ -351,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
@ -366,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
@ -381,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
@ -394,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() {
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
@ -407,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() {
Describe("discArtworkReader", func() {
Describe("fromDiscArtPriority", func() {
var reader *discArtworkReader
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFolders: map[string]bool{"/music/album/cd2": true},
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"/music/album/cd1/disc.jpg",
"/music/album/cd2/disc.jpg",
"/music/album/cd2/disc2.jpg",
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackPath: "/music/album/cd2/track1.flac",
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})

View File

@ -15,6 +15,7 @@ type mediafileArtworkReader struct {
a *artwork
mediafile model.MediaFile
album model.Album
lib libraryView
}
func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) {
@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
if err != nil {
return nil, err
}
lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID)
if err != nil {
return nil, err
}
a := &mediafileArtworkReader{
a: artwork,
mediafile: *mf,
album: *al,
lib: lib,
}
a.cacheKey.artID = artID
a.cacheKey.lastUpdate = mf.UpdatedAt
@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time {
func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff []sourceFunc
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
path := a.mediafile.AbsolutePath()
ff = []sourceFunc{
fromTag(ctx, path),
fromFFmpegTag(ctx, a.a.ffmpeg, path),
fromTag(ctx, a.lib.FS, a.mediafile.Path),
fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)),
}
}
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,

View File

@ -5,9 +5,9 @@ import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
@ -53,7 +53,7 @@ func (f sourceFunc) String() string {
return name
}
func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc {
func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc {
return func() (io.ReadCloser, string, error) {
for _, file := range files {
_, name := filepath.Split(file)
@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc
if !match {
continue
}
f, err := os.Open(file)
f, err := libFS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", file, err)
continue
}
return f, file, err
return f, file, nil
}
return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files)
}
@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*`),
}
func fromTag(ctx context.Context, path string) sourceFunc {
func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
if relPath == "" {
return nil, "", nil
}
f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
f, err := libFS.Open(relPath)
if err != nil {
return nil, "", err
}
rs, ok := f.(io.ReadSeeker)
if !ok {
f.Close()
return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath)
}
tf, err := taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(relPath),
)
if err != nil {
f.Close()
return nil, "", err
}
// Close in LIFO order: tf first (it holds rs internally), then f.
defer f.Close()
defer tf.Close()
images := f.Properties().Images
images := tf.Properties().Images
if len(images) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
return nil, "", fmt.Errorf("no embedded image found in %s", relPath)
}
imageIndex := findBestImageIndex(ctx, images, path)
data, err := f.Image(imageIndex)
imageIndex := findBestImageIndex(ctx, images, relPath)
data, err := tf.Image(imageIndex)
if err != nil || len(data) == 0 {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
return nil, "", fmt.Errorf("could not load embedded image from %s", relPath)
}
return io.NopCloser(bytes.NewReader(data)), path, nil
return io.NopCloser(bytes.NewReader(data)), relPath, nil
}
}
@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str
return 0
}
// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess
// and cannot read from arbitrary fs.FS implementations; piping via stdin is a
// non-trivial refactor with stream/seek implications.
//
// TODO(artwork-musicfs): when the storage backing the library is not local
// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source
// func to return (nil, "", nil) so callers fall through cleanly.
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {

View File

@ -0,0 +1,92 @@
package artwork
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"testing/fstest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("fromExternalFile", func() {
It("opens a matching file via the library FS", func() {
fsys := fstest.MapFS{
"Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, _ := io.ReadAll(r)
Expect(b).To(Equal([]byte("cover-bytes")))
Expect(path).To(Equal("Artist/Album/cover.jpg"))
})
It("returns an error when no file matches", func() {
fsys := fstest.MapFS{
"Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*")
_, _, err := f()
Expect(err).To(HaveOccurred())
})
It("skips files that fail to open and tries the next match", func() {
fsys := fstest.MapFS{
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
}
// "missing/cover.jpg" is in candidates but not in the FS — should be skipped.
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, _ := io.ReadAll(r)
Expect(b).To(Equal([]byte("a")))
Expect(path).To(Equal("a/cover.jpg"))
})
})
var _ = Describe("fromTag", func() {
It("opens an embedded image via fs.FS", func() {
fsys := os.DirFS("tests/fixtures/artist/an-album")
f := fromTag(GinkgoT().Context(), fsys, "test.mp3")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
Expect(path).To(Equal("test.mp3"))
b, _ := io.ReadAll(r)
Expect(b).ToNot(BeEmpty())
})
It("returns nil reader when the relative path is empty", func() {
f := fromTag(GinkgoT().Context(), os.DirFS("."), "")
r, _, err := f()
Expect(err).ToNot(HaveOccurred())
Expect(r).To(BeNil())
})
It("errors when the FS file is not seekable", func() {
fsys := nonSeekableFS{data: []byte("garbage")}
f := fromTag(GinkgoT().Context(), fsys, "x.mp3")
_, _, err := f()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not seekable"))
})
})
// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file.
type nonSeekableFS struct{ data []byte }
func (n nonSeekableFS) Open(name string) (fs.File, error) {
return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil
}
type nonSeekableFile struct{ r *bytes.Reader }
func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) }
func (n *nonSeekableFile) Close() error { return nil }
func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") }

View File

@ -21,6 +21,7 @@ type Claims struct {
ID string // "id" - artwork/mediafile ID
Format string // "f" - audio format
BitRate int // "b" - audio bitrate
ShareID string // "sid" - share ID for share stream tokens
}
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any {
if c.BitRate != 0 {
m["b"] = c.BitRate
}
if c.ShareID != "" {
m["sid"] = c.ShareID
}
return m
}
@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims {
c.BitRate = int(bf)
}
}
var sid string
if err := token.Get("sid", &sid); err == nil {
c.ShareID = sid
}
return c
}

View File

@ -28,6 +28,7 @@ var _ = Describe("Claims", func() {
Expect(m).NotTo(HaveKey("id"))
Expect(m).NotTo(HaveKey("f"))
Expect(m).NotTo(HaveKey("b"))
Expect(m).NotTo(HaveKey("sid"))
})
It("includes expiration and issued-at when set", func() {
@ -52,6 +53,12 @@ var _ = Describe("Claims", func() {
Expect(m).To(HaveKeyWithValue("f", "mp3"))
Expect(m).To(HaveKeyWithValue("b", 192))
})
It("includes share ID claim when set", func() {
c := auth.Claims{ShareID: "abc1234567"}
m := c.ToMap()
Expect(m).To(HaveKeyWithValue("sid", "abc1234567"))
})
})
Describe("ClaimsFromToken", func() {
@ -84,6 +91,7 @@ var _ = Describe("Claims", func() {
ID: "al-456",
Format: "opus",
BitRate: 128,
ShareID: "abc1234567",
}
token, _, err := tokenAuth.Encode(original.ToMap())
Expect(err).NotTo(HaveOccurred())
@ -91,6 +99,7 @@ var _ = Describe("Claims", func() {
c := auth.ClaimsFromToken(token)
Expect(c.Issuer).To(Equal("ND"))
Expect(c.ID).To(Equal("al-456"))
Expect(c.ShareID).To(Equal("abc1234567"))
Expect(c.Format).To(Equal("opus"))
Expect(c.BitRate).To(Equal(128))
})

View File

@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
}
if err == nil && len(songs) > 0 {
return e.matcher.MatchSongsToLibrary(ctx, songs, count)
return e.matcher.MatchSongs(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
}
}
mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count)
mfs, err := e.matcher.MatchSongs(ctx, songs, count)
if err != nil {
return nil, err
}

View File

@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -57,6 +58,11 @@ func New() FFmpeg {
return &ffmpeg{}
}
// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the
// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to
// detect this specific case and fall back to static resize.
var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp")
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
@ -86,6 +92,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max
if err != nil {
return nil, err
}
if !animWebP.has(cmdPath, "libwebp_anim") {
return nil, ErrAnimatedWebPUnsupported
}
args := []string{cmdPath, "-i", "pipe:0"}
if maxSize > 0 {
@ -98,6 +107,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max
return e.start(ctx, args, reader)
}
// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word
// match of encoder name. The output has rows like " V....D libwebp_anim ..."
// where the name is the 2nd whitespace-separated field.
func parseEncodersOutput(out []byte, name string) bool {
for line := range strings.SplitSeq(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[1] == name {
return true
}
}
return false
}
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
@ -538,6 +560,49 @@ func ffmpegCmd() (string, error) {
return ffmpegPath, ffmpegErr
}
type encoderProbeState uint8
const (
encoderProbeUnknown encoderProbeState = iota
encoderProbeAvailable
encoderProbeUnavailable
)
type encoderProbe struct {
mu sync.Mutex
state encoderProbeState
}
func (p *encoderProbe) has(cmdPath, encoder string) bool {
p.mu.Lock()
defer p.mu.Unlock()
switch p.state {
case encoderProbeAvailable:
return true
case encoderProbeUnavailable:
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec
if err != nil {
log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err)
return false
}
if parseEncodersOutput(out, encoder) {
p.state = encoderProbeAvailable
return true
}
p.state = encoderProbeUnavailable
log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images",
"path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)")
return false
}
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
var (
ffOnce sync.Once
@ -545,4 +610,5 @@ var (
ffmpegErr error
probeOnce sync.Once
probeAvail bool
animWebP encoderProbe
)

View File

@ -3,8 +3,10 @@ package ffmpeg
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
sync "sync"
"testing"
"time"
@ -693,4 +695,57 @@ var _ = Describe("ffmpeg", func() {
})
})
})
Describe("parseEncodersOutput", func() {
const sample = `Encoders:
V..... = Video
------
V....D apng APNG (Animated Portable Network Graphics) image
V....D libwebp_anim libwebp WebP image (codec webp)
V....D libwebp libwebp WebP image (codec webp)
A....D aac AAC (Advanced Audio Coding)
`
It("returns true when the encoder is present", func() {
Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue())
Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue())
Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue())
})
It("returns false when the encoder is absent", func() {
Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse())
Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse())
})
It("does not match partial names", func() {
// libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word.
stripped := `Encoders:
V....D libwebp libwebp WebP image (codec webp)
`
Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse())
})
It("handles empty output", func() {
Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse())
Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse())
})
})
Describe("ConvertAnimatedImage", func() {
// Point ffmpegCmd at a stand-in binary that produces empty `-encoders`
// output so hasAnimatedWebPEncoder returns false. /usr/bin/true is
// portable across POSIX systems.
It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() {
truePath, err := exec.LookPath("true")
if err != nil {
Skip("true(1) not available")
}
origPath, origErr := ffmpegPath, ffmpegErr
ffmpegPath = truePath
ffmpegErr = nil
defer func() {
ffmpegPath, ffmpegErr = origPath, origErr
}()
ff := &ffmpeg{}
_, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75)
Expect(err).To(MatchError(ErrAnimatedWebPUnsupported))
})
})
})

View File

@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher {
return &Matcher{ds: ds}
}
// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// MatchSongs matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
//
// # Algorithm Overview
@ -107,25 +107,58 @@ func New(ds model.DataStore) *Matcher {
//
// Returns up to 'count' MediaFiles from the library that best match the input songs,
// preserving the original order from the agent. Songs that cannot be matched are skipped.
func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := m.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
if len(songs) == 0 {
return nil, nil
}
return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil
}
// MatchSongsIndexed matches agent song results to local library tracks and returns a map
// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map.
// This preserves original indices, allowing callers to correlate results back to the input slice.
func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) {
if len(songs) == 0 {
return nil, nil
}
byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs)
if err != nil {
return nil, err
}
result := make(map[int]model.MediaFile, len(songs))
for i, t := range songs {
if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found {
result[i] = mf
}
}
return result, nil
}
func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) {
byID, err = m.loadTracksByID(ctx, songs)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
byMBID, err = m.loadTracksByMBID(ctx, songs, byID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
}
byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
return byID, byMBID, byISRC, byTitle, nil
}
// songMatchedIn checks if a song has already been matched in any of the provided match maps.

View File

@ -75,7 +75,7 @@ var _ = Describe("Matcher", func() {
Return(artistTracks, nil).Maybe()
}
Describe("MatchSongsToLibrary", func() {
Describe("MatchSongs", func() {
Context("matching by direct ID", func() {
It("matches songs with an ID field to MediaFiles by ID", func() {
conf.Server.Matcher.FuzzyThreshold = 100
@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() {
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-1"))
@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() {
}
expectMBIDPhase(model.MediaFiles{mbidMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-mbid"))
@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() {
}
expectISRCPhase(model.MediaFiles{isrcMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-isrc"))
@ -142,7 +142,7 @@ var _ = Describe("Matcher", func() {
ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode",
}
setupTitleOnlyExpectations(model.MediaFiles{titleMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-title"))
@ -157,7 +157,7 @@ var _ = Describe("Matcher", func() {
ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-fuzzy"))
@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() {
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
}
setupTitleOnlyExpectations(differentTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
@ -189,7 +189,7 @@ var _ = Describe("Matcher", func() {
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("br-live"))
@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() {
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
}
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("br"))
@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() {
}
expectIDPhase(model.MediaFiles{idMatch})
allowOtherPhases()
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal("track-id"))
@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() {
{ID: "c", Title: "Song C", Artist: "Artist"},
}
setupTitleOnlyExpectations(tracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
result, err := m.MatchSongs(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
})
@ -256,13 +256,60 @@ var _ = Describe("Matcher", func() {
Context("empty input", func() {
It("returns empty results for no songs", func() {
result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5)
result, err := m.MatchSongs(ctx, []agents.Song{}, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
})
Describe("MatchSongsIndexed", func() {
It("returns index-keyed map of matched songs", func() {
songs := []agents.Song{
{ID: "track-1", Name: "Song One", Artist: "Artist A"},
{ID: "track-2", Name: "Song Two", Artist: "Artist B"},
{ID: "track-3", Name: "Song Three", Artist: "Artist C"},
}
mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"}
mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"}
expectIDPhase(model.MediaFiles{mf1, mf2})
allowOtherPhases()
result, err := m.MatchSongsIndexed(ctx, songs)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].ID).To(Equal("track-1"))
Expect(result[1].ID).To(Equal("track-2"))
_, exists := result[2]
Expect(exists).To(BeFalse())
})
It("preserves original indices when some songs don't match", func() {
songs := []agents.Song{
{Name: "Unknown Song", Artist: "Unknown Artist"},
{ID: "track-1", Name: "Known Song", Artist: "Known Artist"},
}
mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"}
expectIDPhase(model.MediaFiles{mf1})
allowOtherPhases()
result, err := m.MatchSongsIndexed(ctx, songs)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
_, exists := result[0]
Expect(exists).To(BeFalse())
Expect(result[1].ID).To(Equal("track-1"))
})
It("returns empty map for empty input", func() {
result, err := m.MatchSongsIndexed(ctx, nil)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("specificity level matching", func() {
BeforeEach(func() {
conf.Server.Matcher.FuzzyThreshold = 100
@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(artistTracks)
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -495,7 +542,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -515,7 +562,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -535,7 +582,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -556,7 +603,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -577,7 +624,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -603,7 +650,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -620,7 +667,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -640,7 +687,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -657,7 +704,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{differentDuration})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -677,7 +724,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -694,7 +741,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{anyTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -711,7 +758,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{shortTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
@ -737,7 +784,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{libraryTrack})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
@ -757,7 +804,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC})
result, err := m.MatchSongsToLibrary(ctx, songs, 5)
result, err := m.MatchSongs(ctx, songs, 5)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
@ -778,7 +825,7 @@ var _ = Describe("Matcher", func() {
setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB})
result, err := m.MatchSongsToLibrary(ctx, songs, 2)
result, err := m.MatchSongs(ctx, songs, 2)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))

View File

@ -3,6 +3,7 @@ package playlists
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@ -17,14 +18,89 @@ import (
"golang.org/x/text/unicode/norm"
)
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) {
absPath, err := filepath.Abs(absolutePath)
if err != nil {
return nil, fmt.Errorf("resolving absolute path: %w", err)
}
dir := filepath.Dir(absPath)
filename := filepath.Base(absPath)
folder, err := s.resolveFolder(ctx, dir)
if err != nil && !errors.Is(err, errNotInLibrary) {
return nil, err
}
if err == nil {
pls, err := s.importFromFolder(ctx, folder, filename, sync)
if err != nil {
return nil, err
}
if pls.ID != "" && pls.Sync != sync {
pls.Sync = sync
if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil {
return nil, putErr
}
}
return pls, nil
}
log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath)
pls, err := s.newSyncedPlaylist(dir, filename)
if err != nil {
return nil, fmt.Errorf("reading playlist file: %w", err)
}
pls.Sync = sync
file, err := os.Open(absPath)
if err != nil {
return nil, fmt.Errorf("opening playlist file: %w", err)
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
return nil, err
}
if err := s.updatePlaylist(ctx, pls, sync); err != nil {
return nil, err
}
return pls, nil
}
var errNotInLibrary = fmt.Errorf("path not in any library")
func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return nil, err
}
matcher := newLibraryMatcher(libs)
lib, ok := matcher.findLibrary(dir)
if !ok {
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
}
folder, err := s.ds.Folder(ctx).GetByPath(lib, dir)
if err != nil {
return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err)
}
folder.LibraryPath = lib.Path
return folder, nil
}
func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
return s.importFromFolder(ctx, folder, filename, false)
}
func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*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)
err = s.updatePlaylist(ctx, pls, forceSync)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
}
@ -74,27 +150,31 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
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)
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
// normalization forms to handle cross-platform filesystem differences.
func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).FindByPath(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)
altPath := norm.NFD.String(path)
if altPath == path {
altPath = norm.NFC.String(path)
}
if altPath != newPls.Path {
if altPath != path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
return pls, err
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error {
owner, _ := request.UserFrom(ctx)
pls, err := s.findByPathNormalized(ctx, newPls.Path)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync
if alreadyImportedAndNotSynced {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
}

View File

@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFile", func() {
Describe("ImportFromFolder", func() {
var folder *model.Folder
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -59,7 +59,7 @@ var _ = Describe("Playlists - Import", func() {
Describe("M3U", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "pls1.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(2))
@ -69,19 +69,19 @@ var _ = Describe("Playlists - Import", func() {
})
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists with UTF-8 BOM marker", func() {
pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Test Playlist"))
@ -90,7 +90,7 @@ var _ = Describe("Playlists - Import", func() {
})
It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() {
pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
@ -101,7 +101,7 @@ var _ = Describe("Playlists - Import", func() {
It("parses #EXTALBUMARTURL with HTTP URL", func() {
conf.Server.EnableM3UExternalAlbumArt = true
pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u")
pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
Expect(pls.Tracks).To(HaveLen(2))
@ -121,7 +121,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
@ -139,7 +139,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
})
@ -158,7 +158,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
@ -177,7 +177,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(imgPath))
})
@ -195,7 +195,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -212,7 +212,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -229,7 +229,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -247,7 +247,7 @@ var _ = Describe("Playlists - Import", func() {
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -275,12 +275,38 @@ var _ = Describe("Playlists - Import", func() {
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
})
It("skips non-synced playlist on re-import (respects user's choice)", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: plsFile,
Sync: false,
OwnerID: "123",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// updatePlaylist skips the non-synced playlist, so the returned
// playlist has no ID (was never persisted/updated).
Expect(pls.ID).To(BeEmpty())
})
It("clears ExternalImageURL on re-scan when directive is removed", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
@ -301,7 +327,7 @@ var _ = Describe("Playlists - Import", func() {
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
@ -309,7 +335,7 @@ var _ = Describe("Playlists - Import", func() {
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
@ -322,17 +348,17 @@ var _ = Describe("Playlists - Import", func() {
})
It("returns an error if the playlist is not well-formed", func() {
tests.SkipOnWindows("line-ending differences affect JSON error offset")
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
_, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp")
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
It("parses NSP with public: true and creates public playlist", func() {
pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Public Playlist"))
Expect(pls.Public).To(BeTrue())
})
It("parses NSP with public: false and creates private playlist", func() {
pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Private Playlist"))
Expect(pls.Public).To(BeFalse())
@ -340,7 +366,7 @@ var _ = Describe("Playlists - Import", func() {
It("uses server default when public field is absent", func() {
conf.Server.DefaultPlaylistPublicVisibility = true
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
@ -386,7 +412,7 @@ var _ = Describe("Playlists - Import", func() {
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u")
Expect(err).ToNot(HaveOccurred())
// Should update existing playlist, not create new one
@ -441,7 +467,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
@ -462,7 +488,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should only find abc.mp3, not outside.mp3
Expect(pls.Tracks).To(HaveLen(1))
@ -499,7 +525,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "subfolder", // The folder name
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library
@ -542,7 +568,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library
@ -593,7 +619,7 @@ var _ = Describe("Playlists - Import", func() {
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
// Should have BOTH tracks, not just one
@ -616,6 +642,126 @@ var _ = Describe("Playlists - Import", func() {
})
})
Describe("ImportFile", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
})
It("resolves file inside a library and imports it", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: tmpDir,
Path: "",
Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n"
plsFile := filepath.Join(tmpDir, "my-playlist.m3u")
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Playlist"))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Path).To(Equal(plsFile))
Expect(pls.Sync).To(BeTrue())
})
It("records path for files outside all libraries", func() {
tmpDir := GinkgoT().TempDir()
libDir := filepath.Join(tmpDir, "music")
Expect(os.Mkdir(libDir, 0755)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}})
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n"
plsFile := filepath.Join(tmpDir, "external.m3u")
Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("External Playlist"))
Expect(pls.Path).To(Equal(plsFile))
Expect(pls.Sync).To(BeFalse())
})
It("imports with Sync=false", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Sync).To(BeFalse())
})
It("imports with Sync=true", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Sync).To(BeTrue())
})
It("upgrades non-synced playlist to synced on re-import with sync=true", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "existing-id", Name: "Existing", Path: plsFile,
Sync: false, OwnerID: "123",
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
pls, err := ps.ImportFile(ctx, plsFile, true)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Sync).To(BeTrue())
})
})
Describe("ImportM3U", func() {
var repo *mockedMediaFileFromListRepo
BeforeEach(func() {
@ -925,3 +1071,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
}
return mfs, nil
}
type mockFolderRepoForImport struct {
model.FolderRepository
folder *model.Folder
}
func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) {
if m.folder != nil {
return m.folder, nil
}
return nil, model.ErrNotFound
}

View File

@ -163,17 +163,26 @@ type libraryMatcher struct {
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
lib, ok := lm.findLibrary(absolutePath)
if !ok {
return 0, ""
}
return lib.ID, filepath.Clean(lib.Path)
}
// findLibrary checks if the absolute path is under any of the library paths.
func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i].ID, cleanLibPath
return lm.libraries[i], true
}
}
}
return 0, ""
return model.Library{}, false
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).

View File

@ -42,10 +42,11 @@ type Playlists interface {
RemoveImage(ctx context.Context, playlistID string) error
// Import
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error)
ImportFromFolder(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)
// REST adapters
NewRepository(ctx context.Context) rest.Repository
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
}

View File

@ -3,9 +3,11 @@ package playlists
import (
"context"
"errors"
"reflect"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
)
@ -32,8 +34,8 @@ 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) Update(id string, entity any, _ ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist))
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
@ -77,7 +79,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
// 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 {
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
@ -93,11 +95,45 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
return rest.ErrPermissionDenied
}
// Apply ownership change (admin only)
if entity.OwnerID != "" {
current.OwnerID = entity.OwnerID
contentChanged := entity.Name != current.Name ||
entity.Comment != current.Comment ||
(entity.OwnerID != "" && entity.OwnerID != current.OwnerID) ||
!rulesEqual(current.Rules, entity.Rules)
if contentChanged {
if entity.OwnerID != "" {
current.OwnerID = entity.OwnerID
}
current.Rules = entity.Rules
if current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync
}
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
}
// Apply smart playlist rules update
current.Rules = entity.Rules
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
// Only sync/public changed — skip updatedAt so cover art URLs stay stable
var cols []string
if current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync
cols = append(cols, "sync")
}
if current.Public != entity.Public {
current.Public = entity.Public
cols = append(cols, "public")
}
if len(cols) == 0 {
return nil
}
return s.ds.Playlist(ctx).Put(current, cols...)
}
func rulesEqual(a, b *criteria.Criteria) bool {
if a == b {
return true
}
if a == nil || b == nil {
return false
}
return reflect.DeepEqual(a, b)
}

View File

@ -142,6 +142,76 @@ var _ = Describe("REST Adapter", func() {
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
})
It("allows toggling sync for file-backed playlists", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockPlsRepo.Data["file-pls"] = &model.Playlist{
ID: "file-pls",
Name: "File Playlist",
OwnerID: "user-1",
Path: "/music/playlist.m3u",
Sync: true,
UpdatedAt: originalTime,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "File Playlist", Sync: false}
err := repo.Update("file-pls", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
})
It("does not allow setting sync on non-file-backed playlists", func() {
mockPlsRepo.Data["manual-pls"] = &model.Playlist{
ID: "manual-pls",
Name: "Manual Playlist",
OwnerID: "user-1",
Path: "",
Sync: false,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Manual Playlist", Sync: true}
err := repo.Update("manual-pls", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil())
})
It("does not bump updatedAt when only public changes", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockPlsRepo.Data["pls-pub"] = &model.Playlist{
ID: "pls-pub",
Name: "My Playlist",
OwnerID: "user-1",
Public: false,
UpdatedAt: originalTime,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "My Playlist", Public: true}
err := repo.Update("pls-pub", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Public).To(BeTrue())
Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime))
})
It("bumps updatedAt when name changes along with sync", func() {
mockPlsRepo.Data["file-pls2"] = &model.Playlist{
ID: "file-pls2",
Name: "Old Name",
OwnerID: "user-1",
Path: "/music/playlist.m3u",
Sync: true,
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "New Name", Sync: false}
err := repo.Update("file-pls2", pls)
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.Name).To(Equal("New Name"))
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
})
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)

View File

@ -3,7 +3,7 @@ package scrobbler
import (
"context"
"maps"
"sort"
"slices"
"sync"
"time"
@ -17,13 +17,30 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
const (
StateStarting = "starting"
StatePlaying = "playing"
StatePaused = "paused"
StateStopped = "stopped"
)
var ValidStates = map[string]bool{
StateStarting: true,
StatePlaying: true,
StatePaused: true,
StateStopped: true,
}
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
Position int
Username string
PlayerId string
PlayerName string
MediaFile model.MediaFile
Start time.Time
Username string
PlayerId string
PlayerName string
State string
PositionMs int64
PlaybackRate float64
LastReport time.Time
}
type Submission struct {
@ -31,6 +48,16 @@ type Submission struct {
Timestamp time.Time
}
type ReportPlaybackParams struct {
MediaId string
PositionMs int64
State string
PlaybackRate float64
IgnoreScrobble bool
ClientId string
ClientName string
}
type nowPlayingEntry struct {
ctx context.Context
userId string
@ -39,9 +66,9 @@ type nowPlayingEntry struct {
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) error
ReportPlayback(ctx context.Context, params ReportPlaybackParams) error
}
// PluginLoader is a minimal interface for plugin manager usage in PlayTracker
@ -72,8 +99,12 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
})
}
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
// the GetPlayTracker function above
// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton,
// returned by the GetPlayTracker function above. This constructor is exported for testing.
func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
return newPlayTracker(ds, broker, pluginManager)
}
func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{
@ -193,36 +224,103 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler {
return combined
}
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
if err != nil {
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
return err
func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration {
if rate <= 0 {
rate = 1.0
}
remainingMs := float64(int64(durationSec*1000)-positionMs) / rate
remainingSec := max(int(remainingMs/1000), 0)
return time.Duration(remainingSec+5) * time.Second
}
func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error {
player, _ := request.PlayerFrom(ctx)
user, _ := request.UserFrom(ctx)
info := NowPlayingInfo{
MediaFile: *mf,
Start: time.Now(),
Position: position,
Username: user.UserName,
PlayerId: playerId,
PlayerName: playerName,
clientId := params.ClientId
client := params.ClientName
now := time.Now()
switch params.State {
case StateStarting:
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
info := NowPlayingInfo{
MediaFile: *mf,
Start: now,
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
State: params.State,
PositionMs: params.PositionMs,
PlaybackRate: params.PlaybackRate,
LastReport: now,
}
err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate))
if err != nil {
log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
case StatePlaying, StatePaused:
info, getErr := p.playMap.Get(clientId)
if getErr != nil || info.MediaFile.ID != params.MediaId {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
info = NowPlayingInfo{
MediaFile: *mf,
Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond),
Username: user.UserName,
PlayerId: clientId,
PlayerName: client,
}
}
info.State = params.State
info.PositionMs = params.PositionMs
info.PlaybackRate = params.PlaybackRate
info.LastReport = now
ttl := 30 * time.Minute
if params.State == StatePlaying {
ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate)
}
err := p.playMap.AddWithTTL(clientId, info, ttl)
if err != nil {
log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err)
}
case StateStopped:
if !params.IgnoreScrobble && player.ScrobbleEnabled {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId)
if err != nil {
return err
}
trackDurationMs := int64(mf.Duration * 1000)
threshold := min(trackDurationMs*50/100, 240_000)
if params.PositionMs >= threshold {
err = p.incPlay(ctx, mf, now)
if err != nil {
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
}
p.dispatchScrobble(ctx, mf, now)
}
}
p.playMap.Remove(clientId)
}
// Calculate TTL based on remaining track duration. If position exceeds track duration,
// remaining is set to 0 to avoid negative TTL.
remaining := max(int(mf.Duration)-position, 0)
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl)
if conf.Server.EnableNowPlaying {
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position)
if !params.IgnoreScrobble && player.ScrobbleEnabled &&
(params.State == StateStarting || params.State == StatePlaying) {
if info, err := p.playMap.Get(clientId); err == nil {
p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000))
}
}
return nil
}
@ -296,9 +394,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
res := p.playMap.Values()
sort.Slice(res, func(i, j int) bool {
return res[i].Start.After(res[j].Start)
slices.SortFunc(res, func(a, b NowPlayingInfo) int {
return b.Start.Compare(a.Start)
})
for i := range res {
if res[i].State == StatePlaying {
elapsed := time.Since(res[i].LastReport).Milliseconds()
estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate)
trackDurationMs := int64(res[i].MediaFile.Duration * 1000)
res[i].PositionMs = min(estimated, trackDurationMs)
}
}
return res, nil
}

View File

@ -20,9 +20,6 @@ import (
. "github.com/onsi/gomega"
)
// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
mu sync.RWMutex
names []string
@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() {
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
})
Describe("NowPlaying", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Expect(fake.GetUserID()).To(Equal("u-1"))
Expect(fake.GetTrack().ID).To(Equal("123"))
Expect(fake.GetTrack().Participants).To(Equal(track.Participants))
})
It("does not send track to agent if user has not authorized", func() {
fake.Authorized = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
pos := 42
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos))
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].Position).To(Equal(pos))
})
It("sends event with count", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
eventList := eventBroker.getEvents()
Expect(eventList).ToNot(BeEmpty())
evt, ok := eventList[0].(*events.NowPlayingCount)
Expect(ok).To(BeTrue())
Expect(evt.Count).To(Equal(1))
})
It("does not send event when disabled", func() {
conf.Server.EnableNowPlaying = false
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
It("passes user to scrobbler via context (fix for issue #4787)", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
// Verify the username was passed through async dispatch via context
Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser"))
})
})
Describe("GetNowPlaying", func() {
It("returns current playing music", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one",
})
ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true})
_ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
playing, err := tracker.GetNowPlaying(ctx)
@ -336,6 +263,442 @@ var _ = Describe("PlayTracker", func() {
})
})
Describe("ReportPlayback", func() {
const defaultClientId = "client-1"
BeforeEach(func() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true})
})
It("creates entry on starting and removes on stopped", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].MediaFile.ID).To(Equal("123"))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty())
})
It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000)))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing[0].State).To(Equal("paused"))
Expect(playing[0].PositionMs).To(Equal(int64(30000)))
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err = tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty())
})
It("starting replaces existing entry for same player", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("starting"))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("multiple players have independent sessions", func() {
ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true})
ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"})
ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true})
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1",
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{
MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2",
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(2))
})
Describe("SSE broadcast on state change", func() {
BeforeEach(func() {
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake
})
It("broadcasts NowPlayingCount on every state change", func() {
// starting -> count should be 1
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts := eventBroker.getEvents()
Expect(evts).To(HaveLen(1))
Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1))
// playing -> count should be 1
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(2))
Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1))
// paused -> count should be 1
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(3))
Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1))
// stopped -> count should be 0
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
evts = eventBroker.getEvents()
Expect(evts).To(HaveLen(4))
Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0))
})
It("does NOT broadcast when EnableNowPlaying is false", func() {
conf.Server.EnableNowPlaying = false
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = fake
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(eventBroker.getEvents()).To(BeEmpty())
})
})
Describe("auto-scrobble", func() {
It("scrobbles on stopped when positionMs >= 50% of track", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
Expect(artist1.PlayCount).To(Equal(int64(1)))
})
It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() {
longTrack := model.MediaFile{
ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1",
Duration: 600,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")},
},
}
_ = ds.MediaFile(ctx).Put(&longTrack)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(longTrack.PlayCount).To(Equal(int64(1)))
})
It("does NOT scrobble when positionMs below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() {
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
It("scrobbles twice for two separate sessions of same song", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(2)))
})
It("dispatches to external scrobblers on auto-scrobble", func() {
fake.ScrobbleCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
err = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
})
})
Describe("position estimation", func() {
It("estimates position for playing state based on elapsed time", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000)))
})
It("does NOT estimate for paused", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(10000)))
})
It("does NOT estimate for starting", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(0)))
})
It("respects playbackRate", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(100 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
// At 2x speed, 100ms real time = ~200ms playback time
Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100)))
})
It("caps estimated position at track duration", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
time.Sleep(50 * time.Millisecond)
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000
})
})
Describe("resilience (no prior starting)", func() {
It("playing without prior starting creates entry with Start approx now - positionMs", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("playing"))
expectedStart := time.Now().Add(-30 * time.Second)
Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second))
})
It("paused without prior starting creates entry", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].State).To(Equal("paused"))
})
It("stopped without prior starting auto-scrobbles if threshold met", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
})
It("stopped without prior starting does NOT scrobble if below threshold", func() {
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0)))
})
})
Describe("external scrobbler dispatch", func() {
It("dispatches NowPlaying on starting", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("dispatches NowPlaying on playing", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("does NOT dispatch on paused", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ignoreScrobble=true", func() {
fake.nowPlayingCalled.Store(false)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
IgnoreScrobble: true,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("does NOT dispatch when ScrobbleEnabled=false", func() {
fake.nowPlayingCalled.Store(false)
ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false})
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId,
})
Expect(err).ToNot(HaveOccurred())
Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse())
})
})
})
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
var pluginFake *fakeScrobbler
@ -354,27 +717,32 @@ var _ = Describe("PlayTracker", func() {
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("removes plugin scrobbler if not present anymore", func() {
// First call: plugin present
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
// Should not be called since plugin was removed
_ = tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
fake.nowPlayingCalled.Store(false)
pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.ReportPlayback(ctx, ReportPlaybackParams{
MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1",
})
Expect(err).ToNot(HaveOccurred())
Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
@ -550,6 +918,24 @@ var _ = Describe("PlayTracker", func() {
})
})
var _ = DescribeTable("remainingTTL",
func(durationSec float32, positionMs int64, rate float64, expected time.Duration) {
Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected))
},
Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second),
Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second),
Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second),
Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second),
Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second),
Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second),
Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second),
Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second),
Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second),
Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second),
Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second),
Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second),
)
type fakeScrobbler struct {
Authorized bool
nowPlayingCalled atomic.Bool
@ -577,17 +963,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile {
return f.track.Load()
}
func (f *fakeScrobbler) GetPosition() int {
return int(f.position.Load())
}
func (f *fakeScrobbler) GetUsername() string {
if p := f.username.Load(); p != nil {
return *p
}
return ""
}
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}

130
core/sonic/sonic.go Normal file
View File

@ -0,0 +1,130 @@
package sonic
import (
"context"
"fmt"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
const capabilitySonicSimilarity = "SonicSimilarity"
type SimilarResult struct {
Song agents.Song
Similarity float64
}
type SimilarMatch struct {
MediaFile model.MediaFile
Similarity float64
}
type Provider interface {
GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error)
FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error)
}
type PluginLoader interface {
PluginNames(capability string) []string
LoadSonicSimilarity(name string) (Provider, bool)
}
type Sonic struct {
ds model.DataStore
pluginLoader PluginLoader
matcher *matcher.Matcher
}
func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic {
return &Sonic{
ds: ds,
pluginLoader: pluginLoader,
matcher: matcher,
}
}
func (s *Sonic) HasProvider() bool {
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
}
func (s *Sonic) loadProvider() (Provider, error) {
names := s.pluginLoader.PluginNames(capabilitySonicSimilarity)
if len(names) == 0 {
return nil, model.ErrNotFound
}
provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0])
if !ok {
return nil, model.ErrNotFound
}
return provider, nil
}
func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) {
songs := make([]agents.Song, len(results))
for i, r := range results {
songs[i] = r.Song
}
matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs)
if err != nil {
return nil, fmt.Errorf("matching songs to library: %w", err)
}
var matches []SimilarMatch
for i, r := range results {
if mf, ok := matchMap[i]; ok {
matches = append(matches, SimilarMatch{
MediaFile: mf,
Similarity: r.Similarity,
})
}
}
return matches, nil
}
func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) {
provider, err := s.loadProvider()
if err != nil {
return nil, err
}
mf, err := s.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, fmt.Errorf("getting media file %s: %w", id, err)
}
results, err := provider.GetSonicSimilarTracks(ctx, mf, count)
if err != nil {
log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err)
return nil, err
}
return s.resolveMatches(ctx, results)
}
func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) {
provider, err := s.loadProvider()
if err != nil {
return nil, err
}
startMF, err := s.ds.MediaFile(ctx).Get(startID)
if err != nil {
return nil, fmt.Errorf("getting start media file %s: %w", startID, err)
}
endMF, err := s.ds.MediaFile(ctx).Get(endID)
if err != nil {
return nil, fmt.Errorf("getting end media file %s: %w", endID, err)
}
results, err := provider.FindSonicPath(ctx, startMF, endMF, count)
if err != nil {
log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err)
return nil, err
}
return s.resolveMatches(ctx, results)
}

View File

@ -0,0 +1,17 @@
package sonic_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSonic(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Sonic Suite")
}

146
core/sonic/sonic_test.go Normal file
View File

@ -0,0 +1,146 @@
package sonic_test
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockPluginLoader struct {
names []string
provider sonic.Provider
loadOk bool
}
func (m *mockPluginLoader) PluginNames(capability string) []string {
if capability == "SonicSimilarity" {
return m.names
}
return nil
}
func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
return m.provider, m.loadOk
}
type mockProvider struct {
similarResults []sonic.SimilarResult
similarErr error
pathResults []sonic.SimilarResult
pathErr error
}
func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
return m.similarResults, m.similarErr
}
func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) {
return m.pathResults, m.pathErr
}
var _ = Describe("Sonic", func() {
var (
ctx context.Context
ds *tests.MockDataStore
loader *mockPluginLoader
service *sonic.Sonic
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{}
loader = &mockPluginLoader{}
})
Describe("HasProvider", func() {
It("returns false when no plugins available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
Expect(service.HasProvider()).To(BeFalse())
})
It("returns true when a plugin is available", func() {
loader.names = []string{"test-plugin"}
service = sonic.New(ds, loader, nil)
Expect(service.HasProvider()).To(BeTrue())
})
})
Describe("GetSonicSimilarTracks", func() {
It("returns error when no plugin available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
_, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns error when media file not found", func() {
loader.names = []string{"test-plugin"}
loader.provider = &mockProvider{}
loader.loadOk = true
ds.MockedMediaFile = &tests.MockMediaFileRepo{}
service = sonic.New(ds, loader, matcher.New(ds))
_, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10)
Expect(err).To(HaveOccurred())
})
It("returns matched results from plugin", func() {
mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"}
mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"}
mockRepo := tests.CreateMockMediaFileRepo()
mockRepo.SetData(model.MediaFiles{mf1, mf2})
ds.MockedMediaFile = mockRepo
provider := &mockProvider{
similarResults: []sonic.SimilarResult{
{Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85},
},
}
loader.names = []string{"test-plugin"}
loader.provider = provider
loader.loadOk = true
service = sonic.New(ds, loader, matcher.New(ds))
matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10)
Expect(err).ToNot(HaveOccurred())
Expect(matches).To(HaveLen(1))
Expect(matches[0].MediaFile.ID).To(Equal("song-2"))
Expect(matches[0].Similarity).To(Equal(0.85))
})
})
Describe("FindSonicPath", func() {
It("returns error when no plugin available", func() {
loader.names = nil
service = sonic.New(ds, loader, nil)
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns error when plugin call fails", func() {
mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"}
mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"}
mockRepo := tests.CreateMockMediaFileRepo()
mockRepo.SetData(model.MediaFiles{mf1, mf2})
ds.MockedMediaFile = mockRepo
provider := &mockProvider{pathErr: errors.New("plugin error")}
loader.names = []string{"test-plugin"}
loader.provider = provider
loader.loadOk = true
service = sonic.New(ds, loader, matcher.New(ds))
_, err := service.FindSonicPath(ctx, "song-1", "song-2", 25)
Expect(err).To(HaveOccurred())
})
})
})

11
go.mod
View File

@ -9,7 +9,6 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933
@ -35,15 +34,16 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/kardianos/service v1.2.4
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/lestrrat-go/jwx/v3 v3.1.0
github.com/mattn/go-sqlite3 v1.14.42
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/ginkgo/v2 v2.28.2
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pmezard/go-difflib v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.0
github.com/pressly/goose/v3 v3.27.1
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
@ -112,10 +112,9 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ogier/pflag v0.0.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect

39
go.sum
View File

@ -16,8 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -141,8 +139,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -169,14 +167,14 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw=
github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
@ -195,8 +193,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
@ -205,21 +203,20 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@ -321,8 +318,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -422,11 +417,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

View File

@ -1,17 +1,17 @@
// Package criteria implements a Criteria API based on Masterminds/squirrel
// Package criteria implements the smart playlist criteria DSL.
package criteria
import (
"encoding/json"
"errors"
"fmt"
"strings"
"slices"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
type Expression = squirrel.Sqlizer
type Expression interface {
fields() map[string]any
}
type Criteria struct {
Expression
@ -43,125 +43,35 @@ func (c Criteria) EffectiveLimit(totalCount int64) int {
return 0
}
// ResolveLimit converts a percentage-based limit into an absolute Limit using
// the given totalCount. It is a no-op when a fixed Limit is already set or when
// no percentage limit is configured.
func (c *Criteria) ResolveLimit(totalCount int64) {
if !c.IsPercentageLimit() {
return
}
c.Limit = c.EffectiveLimit(totalCount)
}
// IsPercentageLimit returns true when the criteria uses a valid percentage-based
// limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it).
func (c Criteria) IsPercentageLimit() bool {
return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
}
func (c Criteria) OrderBy() string {
if c.Sort == "" {
c.Sort = "title"
}
order := strings.ToLower(strings.TrimSpace(c.Order))
if order != "" && order != "asc" && order != "desc" {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
order = ""
}
parts := strings.Split(c.Sort, ",")
fields := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
dir := "asc"
if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") {
if strings.HasPrefix(p, "-") {
dir = "desc"
}
p = strings.TrimSpace(p[1:])
}
sortField := strings.ToLower(p)
f := fieldMap[sortField]
if f == nil {
log.Error("Invalid field in 'sort' field", "sort", sortField)
continue
}
var mapped string
if f.order != "" {
mapped = f.order
} else if f.isTag {
// Use the actual field name (handles aliases like albumtype -> releasetype)
tagName := sortField
if f.field != "" {
tagName = f.field
}
mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
} else if f.isRole {
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
} else {
mapped = f.field
}
if f.numeric {
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
}
// If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction.
// This ensures that the global order applies consistently across all fields.
if order == "desc" {
if dir == "asc" {
dir = "desc"
} else {
dir = "asc"
}
}
fields = append(fields, mapped+" "+dir)
}
return strings.Join(fields, ", ")
}
func (c Criteria) ToSql() (sql string, args []any, err error) {
return c.Expression.ToSql()
}
// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression,
// excluding any JOINs required solely for sorting. This is useful for COUNT
// queries where sort order is irrelevant.
func (c Criteria) ExpressionJoins() JoinType {
if c.Expression == nil {
return JoinNone
}
return extractJoinTypes(c.Expression)
}
// RequiredJoins inspects the expression tree and Sort field to determine which
// additional JOINs are needed when evaluating this criteria.
func (c Criteria) RequiredJoins() JoinType {
result := JoinNone
if c.Expression != nil {
result |= extractJoinTypes(c.Expression)
}
// Also check Sort fields
if c.Sort != "" {
for _, p := range strings.Split(c.Sort, ",") {
p = strings.TrimSpace(p)
p = strings.TrimLeft(p, "+-")
p = strings.TrimSpace(p)
result |= fieldJoinType(p)
}
}
return result
}
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return nil
}
if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
return parent.ChildPlaylistIds()
parent, ok := c.Expression.(conjunction)
if !ok {
return nil
}
return nil
ids := parent.ChildPlaylistIds()
slices.Sort(ids)
return slices.Compact(ids)
}
func (c Criteria) MarshalJSON() ([]byte, error) {

View File

@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() {
}
jsonObj = b.String()
})
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
`AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` +
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
Describe("OrderBy", func() {
It("sorts by regular fields", func() {
gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
})
It("sorts by tag fields", func() {
goObj.Sort = "genre"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
),
)
})
It("sorts by role fields", func() {
goObj.Sort = "artist"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
),
)
})
It("casts numeric tags when sorting", func() {
AddTagNames([]string{"rate"})
AddNumericTags([]string{"rate"})
goObj.Sort = "rate"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
)
})
It("sorts by albumtype alias (resolves to releasetype)", func() {
AddTagNames([]string{"releasetype"})
goObj.Sort = "albumtype"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
),
)
})
It("sorts by random", func() {
newObj := goObj
newObj.Sort = "random"
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
It("sorts by multiple fields", func() {
goObj.Sort = "title,-rating"
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
"media_file.title asc, COALESCE(annotation.rating, 0) desc",
))
})
It("reverts order when order is desc", func() {
goObj.Sort = "-date,artist"
goObj.Order = "desc"
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
"media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc",
))
})
It("ignores invalid sort fields", func() {
goObj.Sort = "bogus,title"
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
"media_file.title asc",
))
})
})
})
Context("with artist roles", func() {
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Is{"artist": "The Beatles"},
Contains{"composer": "Lennon"},
},
}
})
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
))
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
})
})
Describe("ExpressionJoins", func() {
It("excludes sort-only joins", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone))
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("includes expression-based joins", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
})
Describe("RequiredJoins", func() {
It("returns JoinNone when no annotation fields are used", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinNone for media_file annotation fields", func() {
c := Criteria{
Expression: All{
Is{"loved": true},
Gt{"playCount": 5},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinAlbumAnnotation for album annotation fields", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation))
})
It("returns JoinArtistAnnotation for artist annotation fields", func() {
c := Criteria{
Expression: All{
Is{"artistLoved": true},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation))
})
It("returns both join types when both are used", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
Is{"artistLoved": true},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types in nested expressions", func() {
c := Criteria{
Expression: All{
Any{
All{
Is{"albumLoved": true},
},
},
Any{
Gt{"artistPlayCount": 10},
},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field with direction prefix", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "-artistRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
})
Describe("LimitPercent", func() {
@ -382,6 +177,39 @@ var _ = Describe("Criteria", func() {
})
})
Describe("ResolveLimit", func() {
It("resolves percentage to absolute limit preserving LimitPercent", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
It("does nothing when Limit is already set", func() {
c := Criteria{Limit: 50, LimitPercent: 10}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(50))
})
It("does nothing when no limit is configured", func() {
c := Criteria{}
c.ResolveLimit(1000)
gomega.Expect(c.Limit).To(gomega.Equal(0))
})
It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() {
c := Criteria{LimitPercent: 1}
c.ResolveLimit(5)
gomega.Expect(c.Limit).To(gomega.Equal(1))
})
It("is idempotent when called twice", func() {
c := Criteria{LimitPercent: 10}
c.ResolveLimit(450)
c.ResolveLimit(450)
gomega.Expect(c.Limit).To(gomega.Equal(45))
})
})
Describe("IsPercentageLimit", func() {
It("returns true when LimitPercent is set and Limit is 0", func() {
c := Criteria{LimitPercent: 10}
@ -470,5 +298,23 @@ var _ = Describe("Criteria", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("returns empty list for leaf expressions", func() {
ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("deduplicates repeated playlist IDs", func() {
sharedID := uuid.NewString()
goObj = Criteria{
Expression: All{
InPlaylist{"id": sharedID},
Any{
InPlaylist{"id": sharedID},
NotInPlaylist{"id": sharedID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
})
})
})

View File

@ -1,304 +1,163 @@
package criteria
import (
"fmt"
"reflect"
"strings"
import "strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
Alias string // If set, this field is a backward-compat alias for another canonical name
IsTag bool
IsRole bool
Numeric bool
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
type JoinType int
const (
JoinNone JoinType = 0
JoinAlbumAnnotation JoinType = 1 << iota
JoinArtistAnnotation
)
// Has returns true if j contains all bits in other.
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
var fieldMap = map[string]*mappedField{
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"missing": {field: "media_file.missing"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"codec": {field: "media_file.codec"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"samplerate": {field: "media_file.sample_rate"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"averagerating": {field: "media_file.average_rating", numeric: true},
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
"mbz_album_id": {field: "media_file.mbz_album_id"},
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
"mbz_recording_id": {field: "media_file.mbz_recording_id"},
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
"library_id": {field: "media_file.library_id", numeric: true},
// Backward compatibility: albumtype is an alias for releasetype tag
"albumtype": {field: "releasetype", isTag: true},
// special fields
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
"value": {field: "value"}, // pseudo-field for tag and roles values
tagAlias string // If set, a tag name from mappings.yml that resolves to this field
name string // Canonical name, populated by LookupField from the map key
}
type mappedField struct {
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
joinType JoinType // which additional JOINs this field requires
// Name returns the canonical field name (the map key used to register this field).
func (f FieldInfo) Name() string {
return f.name
}
func mapFields(expr map[string]any) map[string]any {
m := make(map[string]any)
for f, v := range expr {
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
m[dbf.field] = v
var fieldMap = map[string]FieldInfo{
"title": {},
"album": {},
"hascoverart": {},
"tracknumber": {},
"discnumber": {},
"year": {},
"date": {tagAlias: "recordingdate"},
"originalyear": {},
"originaldate": {},
"releaseyear": {},
"releasedate": {},
"size": {},
"compilation": {},
"missing": {},
"explicitstatus": {},
"dateadded": {},
"datemodified": {},
"discsubtitle": {},
"comment": {},
"lyrics": {},
"sorttitle": {},
"sortalbum": {},
"sortartist": {},
"sortalbumartist": {},
"albumcomment": {},
"catalognumber": {},
"filepath": {},
"filetype": {},
"codec": {},
"duration": {},
"bitrate": {},
"bitdepth": {},
"samplerate": {},
"bpm": {},
"channels": {},
"loved": {},
"dateloved": {},
"lastplayed": {},
"daterated": {},
"playcount": {},
"rating": {},
"averagerating": {Numeric: true},
"albumrating": {},
"albumloved": {},
"albumplaycount": {},
"albumlastplayed": {},
"albumdateloved": {},
"albumdaterated": {},
"artistrating": {},
"artistloved": {},
"artistplaycount": {},
"artistlastplayed": {},
"artistdateloved": {},
"artistdaterated": {},
"mbz_album_id": {},
"mbz_album_artist_id": {},
"mbz_artist_id": {},
"mbz_recording_id": {},
"mbz_release_track_id": {},
"mbz_release_group_id": {},
"rgalbumgain": {Numeric: true},
"rgalbumpeak": {Numeric: true},
"rgtrackgain": {Numeric: true},
"rgtrackpeak": {Numeric: true},
"library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Alias: "releasetype", IsTag: true},
// Pseudo-field for random sorting
"random": {},
}
// AllFieldNames returns the names of all registered criteria fields.
func AllFieldNames() []string {
names := make([]string, 0, len(fieldMap))
for name := range fieldMap {
names = append(names, name)
}
return names
}
// LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) {
key := strings.ToLower(name)
f, ok := fieldMap[key]
if ok {
if f.Alias != "" {
f.name = f.Alias
} else {
log.Error("Invalid field in criteria", "field", f)
f.name = key
}
}
return m
}
// mapExpr maps a normal field expression to a specific type of expression (tag or role).
// This is required because tags are handled differently than other fields,
// as they are stored as a JSON column in the database.
func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
rv := reflect.ValueOf(expr)
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
}
// Extract the field name and value, then build a new map keyed by "value"
// for the inner condition. The original map is left untouched so that
// ToSql can be called multiple times without corruption.
var k string
var v any
for _, key := range rv.MapKeys() {
k = key.String()
v = rv.MapIndex(key).Interface()
break // only one key is expected (and supported)
}
// Create a new map-based expression with "value" as the key, matching the
// column name inside json_tree subqueries.
newMap := reflect.MakeMap(rv.Type())
newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v))
newExpr := newMap.Interface().(squirrel.Sqlizer)
return exprFunc(k, newExpr, negate)
}
// mapTagExpr maps a normal field expression to a tag expression.
func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return mapExpr(expr, negate, tagExpr)
}
// mapRoleExpr maps a normal field expression to an artist role expression.
func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return mapExpr(expr, negate, roleExpr)
}
func isTagExpr(expr map[string]any) bool {
for f := range expr {
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
return true
}
}
return false
}
func isRoleExpr(expr map[string]any) bool {
for f := range expr {
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
return true
}
}
return false
}
func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return tagCond{tag: tag, cond: cond, not: negate}
}
type tagCond struct {
tag string
cond squirrel.Sqlizer
not bool
}
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
tagName := e.tag
if fm, ok := fieldMap[e.tag]; ok {
if fm.field != "" {
tagName = fm.field
}
if fm.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)",
tagName, cond)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return roleCond{role: role, cond: cond, not: negate}
}
type roleCond struct {
role string
cond squirrel.Sqlizer
not bool
}
func (e roleCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`,
e.role, cond)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
func fieldJoinType(name string) JoinType {
if f, ok := fieldMap[strings.ToLower(name)]; ok {
return f.joinType
}
return JoinNone
}
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
func extractJoinTypes(expr any) JoinType {
result := JoinNone
switch e := expr.(type) {
case All:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
case Any:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
default:
// Leaf expression: use reflection to check if it's a map with field names
rv := reflect.ValueOf(expr)
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
for _, key := range rv.MapKeys() {
result |= fieldJoinType(key.String())
}
}
}
return result
return f, ok
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
// smart playlists.
func AddRoles(roles []string) {
for _, role := range roles {
name := strings.ToLower(role)
if _, ok := fieldMap[name]; ok {
continue
}
fieldMap[name] = &mappedField{field: name, isRole: true}
fieldMap[name] = FieldInfo{IsRole: true}
}
}
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
// file to the field map, so they can be used in smart playlists.
// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
// configuration file.
func AddTagNames(tagNames []string) {
for _, name := range tagNames {
name := strings.ToLower(name)
for _, tagName := range tagNames {
name := strings.ToLower(tagName)
if _, ok := fieldMap[name]; ok {
continue
}
for _, fm := range fieldMap {
if fm.alias == name {
for key, fm := range fieldMap {
if fm.tagAlias == name {
fm.Alias = key
fm.tagAlias = ""
fieldMap[name] = fm
break
}
}
if _, ok := fieldMap[name]; !ok {
fieldMap[name] = &mappedField{field: name, isTag: true}
fieldMap[name] = FieldInfo{IsTag: true}
}
}
}
// AddNumericTags marks the given tag names as numeric so they can be cast
// when used in comparisons or sorting.
// AddNumericTags adds tags that should be treated as numbers.
func AddNumericTags(tagNames []string) {
for _, name := range tagNames {
name := strings.ToLower(name)
for _, tagName := range tagNames {
name := strings.ToLower(tagName)
if fm, ok := fieldMap[name]; ok {
fm.numeric = true
fm.Numeric = true
fieldMap[name] = fm
} else {
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
fieldMap[name] = FieldInfo{IsTag: true, Numeric: true}
}
}
}

View File

@ -6,11 +6,51 @@ import (
)
var _ = Describe("fields", func() {
Describe("mapFields", func() {
It("ignores random fields", func() {
m := map[string]any{"random": "123"}
m = mapFields(m)
gomega.Expect(m).To(gomega.BeEmpty())
Describe("LookupField", func() {
It("finds built-in fields case-insensitively", func() {
field, ok := LookupField("Title")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name()).To(gomega.Equal("title"))
})
It("resolves aliases to their canonical field name", func() {
field, ok := LookupField("albumtype")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name()).To(gomega.Equal("releasetype"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
It("finds registered tag names", func() {
AddTagNames([]string{"task3_mood"})
field, ok := LookupField("task3_mood")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name()).To(gomega.Equal("task3_mood"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
It("marks registered numeric tags", func() {
AddTagNames([]string{"task3_score"})
AddNumericTags([]string{"task3_score"})
field, ok := LookupField("task3_score")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.IsTag).To(gomega.BeTrue())
gomega.Expect(field.Numeric).To(gomega.BeTrue())
})
It("finds registered roles", func() {
AddRoles([]string{"task3_producer"})
field, ok := LookupField("task3_producer")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name()).To(gomega.Equal("task3_producer"))
gomega.Expect(field.IsRole).To(gomega.BeTrue())
})
})
})

View File

@ -69,6 +69,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
return InPlaylist(m)
case "notinplaylist":
return NotInPlaylist(m)
case "ismissing":
return IsMissing(m)
case "ispresent":
return IsPresent(m)
}
return nil
}

View File

@ -1,23 +1,21 @@
package criteria
import (
"errors"
"fmt"
"reflect"
"strconv"
"time"
"github.com/Masterminds/squirrel"
)
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
type conjunction interface {
ChildPlaylistIds() []string
}
type (
All squirrel.And
All []Expression
And = All
)
func (all All) ToSql() (sql string, args []any, err error) {
return squirrel.And(all).ToSql()
}
func (All) fields() map[string]any { return nil }
func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
@ -28,13 +26,11 @@ func (all All) ChildPlaylistIds() (ids []string) {
}
type (
Any squirrel.Or
Any []Expression
Or = Any
)
func (any Any) ToSql() (sql string, args []any, err error) {
return squirrel.Or(any).ToSql()
}
func (Any) fields() map[string]any { return nil }
func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
@ -44,236 +40,110 @@ func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
type Is squirrel.Eq
type Is map[string]any
type Eq = Is
func (is Is) ToSql() (sql string, args []any, err error) {
if isRoleExpr(is) {
return mapRoleExpr(is, false).ToSql()
}
if isTagExpr(is) {
return mapTagExpr(is, false).ToSql()
}
return squirrel.Eq(mapFields(is)).ToSql()
}
func (is Is) MarshalJSON() ([]byte, error) {
return marshalExpression("is", is)
}
type IsNot squirrel.NotEq
func (is Is) fields() map[string]any { return is }
func (in IsNot) ToSql() (sql string, args []any, err error) {
if isRoleExpr(in) {
return mapRoleExpr(squirrel.Eq(in), true).ToSql()
}
if isTagExpr(in) {
return mapTagExpr(squirrel.Eq(in), true).ToSql()
}
return squirrel.NotEq(mapFields(in)).ToSql()
type IsNot map[string]any
func (isn IsNot) MarshalJSON() ([]byte, error) {
return marshalExpression("isNot", isn)
}
func (in IsNot) MarshalJSON() ([]byte, error) {
return marshalExpression("isNot", in)
}
func (isn IsNot) fields() map[string]any { return isn }
type Gt squirrel.Gt
func (gt Gt) ToSql() (sql string, args []any, err error) {
if isTagExpr(gt) {
return mapTagExpr(gt, false).ToSql()
}
return squirrel.Gt(mapFields(gt)).ToSql()
}
type Gt map[string]any
func (gt Gt) MarshalJSON() ([]byte, error) {
return marshalExpression("gt", gt)
}
type Lt squirrel.Lt
func (gt Gt) fields() map[string]any { return gt }
func (lt Lt) ToSql() (sql string, args []any, err error) {
if isTagExpr(lt) {
return mapTagExpr(squirrel.Lt(lt), false).ToSql()
}
return squirrel.Lt(mapFields(lt)).ToSql()
}
type Lt map[string]any
func (lt Lt) MarshalJSON() ([]byte, error) {
return marshalExpression("lt", lt)
}
type Before squirrel.Lt
func (lt Lt) fields() map[string]any { return lt }
func (bf Before) ToSql() (sql string, args []any, err error) {
return Lt(bf).ToSql()
}
type Before map[string]any
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
}
type After Gt
func (bf Before) fields() map[string]any { return bf }
func (af After) ToSql() (sql string, args []any, err error) {
return Gt(af).ToSql()
}
type After Gt
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
}
type Contains map[string]any
func (af After) fields() map[string]any { return af }
func (ct Contains) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(ct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
if isRoleExpr(ct) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(ct) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
type Contains map[string]any
func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
}
type NotContains map[string]any
func (ct Contains) fields() map[string]any { return ct }
func (nct NotContains) ToSql() (sql string, args []any, err error) {
lk := squirrel.NotLike{}
for f, v := range mapFields(nct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
if isRoleExpr(nct) {
return mapRoleExpr(squirrel.Like(lk), true).ToSql()
}
if isTagExpr(nct) {
return mapTagExpr(squirrel.Like(lk), true).ToSql()
}
return lk.ToSql()
}
type NotContains map[string]any
func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
}
type StartsWith map[string]any
func (nct NotContains) fields() map[string]any { return nct }
func (sw StartsWith) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%s%%", v)
}
if isRoleExpr(sw) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(sw) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
type StartsWith map[string]any
func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
}
func (sw StartsWith) fields() map[string]any { return sw }
type EndsWith map[string]any
func (sw EndsWith) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%%%s", v)
}
if isRoleExpr(sw) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(sw) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
func (ew EndsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("endsWith", ew)
}
func (sw EndsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("endsWith", sw)
}
func (ew EndsWith) fields() map[string]any { return ew }
type InTheRange map[string]any
func (itr InTheRange) ToSql() (sql string, args []any, err error) {
and := squirrel.And{}
for f, v := range mapFields(itr) {
s := reflect.ValueOf(v)
if s.Kind() != reflect.Slice || s.Len() != 2 {
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
}
and = append(and,
squirrel.GtOrEq{f: s.Index(0).Interface()},
squirrel.LtOrEq{f: s.Index(1).Interface()},
)
}
return and.ToSql()
}
func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
}
type InTheLast map[string]any
func (itr InTheRange) fields() map[string]any { return itr }
func (itl InTheLast) ToSql() (sql string, args []any, err error) {
exp, err := inPeriod(itl, false)
if err != nil {
return "", nil, err
}
return exp.ToSql()
}
type InTheLast map[string]any
func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
}
type NotInTheLast map[string]any
func (itl InTheLast) fields() map[string]any { return itl }
func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
exp, err := inPeriod(nitl, true)
if err != nil {
return "", nil, err
}
return exp.ToSql()
}
type NotInTheLast map[string]any
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
func inPeriod(m map[string]any, negate bool) (Expression, error) {
var field string
var value any
for f, v := range mapFields(m) {
field, value = f, v
break
}
str := fmt.Sprintf("%v", value)
v, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return nil, err
}
firstDate := startOfPeriod(v, time.Now())
if negate {
return Or{
squirrel.Lt{field: firstDate},
squirrel.Eq{field: nil},
}, nil
}
return squirrel.Gt{field: firstDate}, nil
}
func (nitl NotInTheLast) fields() map[string]any { return nitl }
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
@ -281,48 +151,47 @@ func startOfPeriod(numDays int64, from time.Time) string {
type InPlaylist map[string]any
func (ipl InPlaylist) ToSql() (sql string, args []any, err error) {
return inList(ipl, false)
}
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("inPlaylist", ipl)
}
func (ipl InPlaylist) fields() map[string]any { return ipl }
type NotInPlaylist map[string]any
func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
return inList(ipl, true)
func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("notInPlaylist", nipl)
}
func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("notInPlaylist", ipl)
func (nipl NotInPlaylist) fields() map[string]any { return nipl }
type IsMissing map[string]any
func (im IsMissing) MarshalJSON() ([]byte, error) {
return marshalExpression("isMissing", im)
}
func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
var playlistid string
var ok bool
if playlistid, ok = m["id"].(string); !ok {
return "", nil, errors.New("playlist id not given")
}
func (im IsMissing) fields() map[string]any { return im }
// Subquery to fetch all media files that are contained in given playlist
// Only evaluate playlist if it is public
subQuery := squirrel.Select("media_file_id").
From("playlist_tracks pl").
LeftJoin("playlist on pl.playlist_id = playlist.id").
Where(squirrel.And{
squirrel.Eq{"pl.playlist_id": playlistid},
squirrel.Eq{"playlist.public": 1}})
subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
type IsPresent map[string]any
if err != nil {
return "", nil, err
}
if negate {
return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
} else {
return "media_file.id IN (" + subQText + ")", subQArgs, nil
func (ip IsPresent) MarshalJSON() ([]byte, error) {
return marshalExpression("isPresent", ip)
}
func (ip IsPresent) fields() map[string]any { return ip }
func IsTruthy(v any) bool {
switch val := v.(type) {
case bool:
return val
case float64:
return val != 0
case string:
b, err := strconv.ParseBool(val)
return err == nil && b
default:
return v != nil
}
}

View File

@ -3,7 +3,6 @@ package criteria_test
import (
"encoding/json"
"fmt"
"time"
. "github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
@ -17,182 +16,6 @@ var _ = BeforeSuite(func() {
})
var _ = Describe("Operators", func() {
rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
DescribeTable("ToSQL",
func(op Expression, expectedSql string, expectedArgs ...any) {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(expectedSql))
gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
},
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
// InPlaylist and NotInPlaylist are special cases
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Album annotation fields
Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true),
Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5),
Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart),
Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart),
Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart),
Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Artist annotation fields
Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3),
Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5),
Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart),
Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart),
Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart),
Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Tag tests
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
// Artist roles tests
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
)
// TODO Validate operators that are not valid for each field type.
XDescribeTable("ToSQL - Invalid Operators",
func(op Expression, expectedError string) {
_, _, err := op.ToSql()
gomega.Expect(err).To(gomega.MatchError(expectedError))
},
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
)
Describe("Custom Tags", func() {
It("generates valid SQL", func() {
AddTagNames([]string{"mood"})
op := EndsWith{"mood": "Soft"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
})
It("casts numeric comparisons", func() {
AddNumericTags([]string{"rate"})
op := Lt{"rate": 6}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(args).To(gomega.HaveExactElements(6))
})
It("skips unknown tag names", func() {
op := EndsWith{"unknown": "value"}
sql, args, _ := op.ToSql()
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
It("supports releasetype as multi-valued tag", func() {
AddTagNames([]string{"releasetype"})
op := Contains{"releasetype": "soundtrack"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
})
It("supports albumtype as alias for releasetype", func() {
AddTagNames([]string{"releasetype"})
op := Contains{"albumtype": "live"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
})
It("supports albumtype alias with Is operator", func() {
AddTagNames([]string{"releasetype"})
op := Is{"albumtype": "album"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("album"))
})
It("supports albumtype alias with IsNot operator", func() {
AddTagNames([]string{"releasetype"})
op := IsNot{"albumtype": "compilation"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
})
})
Describe("Custom Roles", func() {
It("generates valid SQL", func() {
AddRoles([]string{"producer"})
op := EndsWith{"producer": "Eno"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
})
It("skips unknown roles", func() {
op := Contains{"groupie": "Penny Lane"}
sql, args, _ := op.ToSql()
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
})
DescribeTable("ToSql idempotency",
func(expr Expression) {
sql1, args1, err1 := expr.ToSql()
sql2, args2, err2 := expr.ToSql()
gomega.Expect(err1).ToNot(gomega.HaveOccurred())
gomega.Expect(err2).ToNot(gomega.HaveOccurred())
gomega.Expect(sql2).To(gomega.Equal(sql1))
gomega.Expect(args2).To(gomega.Equal(args1))
},
Entry("tag expression", Is{"genre": "Rock"}),
Entry("role expression", Contains{"artist": "Beatles"}),
Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}),
)
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}
@ -223,5 +46,9 @@ var _ = Describe("Operators", func() {
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`),
Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`),
Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`),
Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`),
Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`),
)
})

62
model/criteria/sort.go Normal file
View File

@ -0,0 +1,62 @@
package criteria
import (
"strings"
"github.com/navidrome/navidrome/log"
)
type SortField struct {
Field string
Desc bool
}
func (c Criteria) OrderByFields() []SortField {
sortValue := c.Sort
if sortValue == "" {
sortValue = "title"
}
order := strings.ToLower(strings.TrimSpace(c.Order))
if order != "" && order != "asc" && order != "desc" {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
order = ""
}
parts := strings.Split(sortValue, ",")
fields := make([]SortField, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
desc := false
if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") {
desc = strings.HasPrefix(part, "-")
part = strings.TrimSpace(part[1:])
}
info, ok := LookupField(part)
if !ok {
log.Error("Invalid field in 'sort' field", "sort", part)
continue
}
if order == "desc" {
desc = !desc
}
fields = append(fields, SortField{Field: info.Name(), Desc: desc})
}
if len(fields) == 0 {
log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue)
return []SortField{{Field: "title", Desc: false}}
}
return fields
}
func (c Criteria) SortFieldNames() []string {
sortFields := c.OrderByFields()
names := make([]string, len(sortFields))
for i, sf := range sortFields {
names[i] = sf.Field
}
return names
}

103
model/criteria/sort_test.go Normal file
View File

@ -0,0 +1,103 @@
package criteria
import (
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("OrderByFields", func() {
It("defaults to title ascending when Sort is empty", func() {
c := Criteria{}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses a single field", func() {
c := Criteria{Sort: "title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses descending prefix", func() {
c := Criteria{Sort: "-rating"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}}))
})
It("parses ascending prefix", func() {
c := Criteria{Sort: "+title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("parses multiple comma-separated fields", func() {
c := Criteria{Sort: "title,-rating"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: true},
}))
})
It("inverts directions when Order is desc", func() {
c := Criteria{Sort: "-date,title", Order: "desc"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "date", Desc: false},
{Field: "title", Desc: true},
}))
})
It("skips invalid fields", func() {
c := Criteria{Sort: "bogus,title"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("falls back to title when all fields are invalid", func() {
c := Criteria{Sort: "bogus,invalid"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}}))
})
It("resolves tag aliases (albumtype -> releasetype)", func() {
c := Criteria{Sort: "albumtype"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}}))
})
It("resolves field aliases (recordingdate -> date)", func() {
AddTagNames([]string{"recordingdate"})
c := Criteria{Sort: "recordingdate"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}}))
})
It("handles the random field", func() {
c := Criteria{Sort: "random"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}}))
})
It("ignores invalid Order value", func() {
c := Criteria{Sort: "-title", Order: "invalid"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}}))
})
It("handles whitespace in fields", func() {
c := Criteria{Sort: " title , -rating "}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: true},
}))
})
It("skips empty parts from trailing commas", func() {
c := Criteria{Sort: "title,,rating,"}
gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{
{Field: "title", Desc: false},
{Field: "rating", Desc: false},
}))
})
})
var _ = Describe("SortFieldNames", func() {
It("returns canonical field names", func() {
c := Criteria{Sort: "title,-rating,albumtype"}
gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"}))
})
It("defaults to title when Sort is empty", func() {
c := Criteria{}
gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"}))
})
})

37
model/criteria/walk.go Normal file
View File

@ -0,0 +1,37 @@
package criteria
import "fmt"
type Visitor func(Expression) error
func Walk(expr Expression, visit Visitor) error {
if expr == nil {
return nil
}
if err := visit(expr); err != nil {
return err
}
switch e := expr.(type) {
case All:
for _, child := range e {
if err := Walk(child, visit); err != nil {
return err
}
}
case Any:
for _, child := range e {
if err := Walk(child, visit); err != nil {
return err
}
}
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent:
return nil
default:
return fmt.Errorf("unknown criteria expression type %T", expr)
}
return nil
}
func Fields(expr Expression) map[string]any {
return expr.fields()
}

View File

@ -0,0 +1,64 @@
package criteria
import (
"fmt"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
type unknownExpression struct{}
func (unknownExpression) fields() map[string]any { return nil }
var _ = Describe("Walk", func() {
It("visits the expression tree depth-first", func() {
expr := All{
Contains{"title": "love"},
Any{
Is{"album": "best of"},
Gt{"rating": 3},
},
}
var visited []string
err := Walk(expr, func(expr Expression) error {
visited = append(visited, fmt.Sprintf("%T", expr))
return nil
})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(visited).To(gomega.Equal([]string{
"criteria.All",
"criteria.Contains",
"criteria.Any",
"criteria.Is",
"criteria.Gt",
}))
})
It("stops when the visitor returns an error", func() {
expectedErr := fmt.Errorf("stop")
err := Walk(All{Contains{"title": "love"}}, func(Expression) error {
return expectedErr
})
gomega.Expect(err).To(gomega.MatchError(expectedErr))
})
It("returns fields for leaf expressions", func() {
gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"}))
gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"}))
})
It("returns nil fields for group expressions", func() {
gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil())
})
It("returns an error for unknown expression types", func() {
err := Walk(unknownExpression{}, func(Expression) error { return nil })
gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression"))
})
})

View File

@ -123,7 +123,7 @@ type PlaylistRepository interface {
ResourceRepository
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(pls *Playlist) error
Put(pls *Playlist, cols ...string) error
Get(id string) (*Playlist, error)
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)

501
persistence/criteria_sql.go Normal file
View File

@ -0,0 +1,501 @@
package persistence
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
)
type smartPlaylistJoinType int
const (
smartPlaylistJoinNone smartPlaylistJoinType = 0
smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
smartPlaylistJoinArtistAnnotation
)
func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
return j&other != 0
}
type smartPlaylistField struct {
expr string
order string
joinType smartPlaylistJoinType
}
type smartPlaylistCriteria struct {
criteria.Criteria
owner model.User
}
func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
cSQL := smartPlaylistCriteria{Criteria: c}
for _, opt := range opts {
opt(&cSQL)
}
return cSQL
}
func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) {
return func(c *smartPlaylistCriteria) {
c.owner = owner
}
}
var smartPlaylistFields = map[string]smartPlaylistField{
"title": {expr: "media_file.title"},
"album": {expr: "media_file.album"},
"hascoverart": {expr: "media_file.has_cover_art"},
"tracknumber": {expr: "media_file.track_number"},
"discnumber": {expr: "media_file.disc_number"},
"year": {expr: "media_file.year"},
"date": {expr: "media_file.date"},
"originalyear": {expr: "media_file.original_year"},
"originaldate": {expr: "media_file.original_date"},
"releaseyear": {expr: "media_file.release_year"},
"releasedate": {expr: "media_file.release_date"},
"size": {expr: "media_file.size"},
"compilation": {expr: "media_file.compilation"},
"missing": {expr: "media_file.missing"},
"explicitstatus": {expr: "media_file.explicit_status"},
"dateadded": {expr: "media_file.created_at"},
"datemodified": {expr: "media_file.updated_at"},
"discsubtitle": {expr: "media_file.disc_subtitle"},
"comment": {expr: "media_file.comment"},
"lyrics": {expr: "media_file.lyrics"},
"sorttitle": {expr: "media_file.sort_title"},
"sortalbum": {expr: "media_file.sort_album_name"},
"sortartist": {expr: "media_file.sort_artist_name"},
"sortalbumartist": {expr: "media_file.sort_album_artist_name"},
"albumcomment": {expr: "media_file.mbz_album_comment"},
"catalognumber": {expr: "media_file.catalog_num"},
"filepath": {expr: "media_file.path"},
"filetype": {expr: "media_file.suffix"},
"codec": {expr: "media_file.codec"},
"duration": {expr: "media_file.duration"},
"bitrate": {expr: "media_file.bit_rate"},
"bitdepth": {expr: "media_file.bit_depth"},
"samplerate": {expr: "media_file.sample_rate"},
"bpm": {expr: "media_file.bpm"},
"channels": {expr: "media_file.channels"},
"loved": {expr: "COALESCE(annotation.starred, false)"},
"dateloved": {expr: "annotation.starred_at"},
"lastplayed": {expr: "annotation.play_date"},
"daterated": {expr: "annotation.rated_at"},
"playcount": {expr: "COALESCE(annotation.play_count, 0)"},
"rating": {expr: "COALESCE(annotation.rating, 0)"},
"averagerating": {expr: "media_file.average_rating"},
"albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
"albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation},
"albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
"albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
"albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
"albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
"artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation},
"artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation},
"artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation},
"artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
"artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
"artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
"mbz_album_id": {expr: "media_file.mbz_album_id"},
"mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"},
"mbz_artist_id": {expr: "media_file.mbz_artist_id"},
"mbz_recording_id": {expr: "media_file.mbz_recording_id"},
"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
"rgalbumgain": {expr: "media_file.rg_album_gain"},
"rgalbumpeak": {expr: "media_file.rg_album_peak"},
"rgtrackgain": {expr: "media_file.rg_track_gain"},
"rgtrackpeak": {expr: "media_file.rg_track_peak"},
"library_id": {expr: "media_file.library_id"},
"random": {order: "random()"},
}
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
if c.Criteria.Expression == nil {
return squirrel.Expr("1 = 1"), nil
}
return c.exprSQL(c.Criteria.Expression)
}
func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
switch e := expr.(type) {
case criteria.All:
and := squirrel.And{}
for _, child := range e {
cond, err := c.exprSQL(child)
if err != nil {
return nil, err
}
and = append(and, cond)
}
return and, nil
case criteria.Any:
or := squirrel.Or{}
for _, child := range e {
cond, err := c.exprSQL(child)
if err != nil {
return nil, err
}
or = append(or, cond)
}
return or, nil
case criteria.Is:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Eq(fields)
}, false)
case criteria.IsNot:
return isNotExpr(e)
case criteria.Gt:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Gt(fields)
}, false)
case criteria.Lt:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Lt(fields)
}, false)
case criteria.Before:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Lt(fields)
}, false)
case criteria.After:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Gt(fields)
}, false)
case criteria.Contains:
return likeExpr(e, "%%%v%%", false)
case criteria.NotContains:
return likeExpr(e, "%%%v%%", true)
case criteria.StartsWith:
return likeExpr(e, "%v%%", false)
case criteria.EndsWith:
return likeExpr(e, "%%%v", false)
case criteria.InTheRange:
return rangeExpr(e)
case criteria.InTheLast:
return periodExpr(e, false)
case criteria.NotInTheLast:
return periodExpr(e, true)
case criteria.InPlaylist:
return c.inList(e, false)
case criteria.NotInPlaylist:
return c.inList(e, true)
case criteria.IsMissing:
return missingExpr(e, true)
case criteria.IsPresent:
return missingExpr(e, false)
default:
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
}
}
func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
}
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
return squirrel.NotEq(fields), nil
}
func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
field, value, info, ok := singleField(values)
if !ok {
if len(values) != 1 {
return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
}
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if !info.IsTag && !info.IsRole {
return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field)
}
negate := checkAbsence == criteria.IsTruthy(value)
return jsonExpr(info, nil, negate), nil
}
func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
}
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
return makeCond(fields), nil
}
func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
}
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
if negate {
lk := squirrel.NotLike{}
for field, value := range fields {
lk[field] = fmt.Sprintf(pattern, value)
}
return lk, nil
}
lk := squirrel.Like{}
for field, value := range fields {
lk[field] = fmt.Sprintf(pattern, value)
}
return lk, nil
}
func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) {
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
and := squirrel.And{}
for field, value := range fields {
s := reflect.ValueOf(value)
if s.Kind() != reflect.Slice || s.Len() != 2 {
return nil, fmt.Errorf("invalid range for 'in' operator: %s", value)
}
and = append(and,
squirrel.GtOrEq{field: s.Index(0).Interface()},
squirrel.LtOrEq{field: s.Index(1).Interface()},
)
}
return and, nil
}
func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
var field string
var value any
for f, v := range fields {
field, value = f, v
break
}
days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
if err != nil {
return nil, err
}
firstDate := startOfPeriod(days, time.Now())
if negate {
return squirrel.Or{
squirrel.Lt{field: firstDate},
squirrel.Eq{field: nil},
}, nil
}
return squirrel.Gt{field: firstDate}, nil
}
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
playlistID, ok := values["id"].(string)
if !ok {
return nil, errors.New("playlist id not given")
}
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
if !c.owner.IsAdmin {
if c.owner.ID == "" {
filters = append(filters, squirrel.Eq{"playlist.public": 1})
} else {
filters = append(filters, squirrel.Or{
squirrel.Eq{"playlist.public": 1},
squirrel.Eq{"playlist.owner_id": c.owner.ID},
})
}
}
subQuery := squirrel.Select("media_file_id").
From("playlist_tracks pl").
LeftJoin("playlist on pl.playlist_id = playlist.id").
Where(filters)
subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
if err != nil {
return nil, err
}
if negate {
return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
}
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
}
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole {
return roleCond{role: info.Name(), cond: cond, not: negate}
}
return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
}
type tagCond struct {
tag string
numeric bool
cond squirrel.Sqlizer
not bool
}
func (e tagCond) ToSql() (string, []any, error) {
var cond string
var args []any
var err error
if e.cond != nil {
cond, args, err = e.cond.ToSql()
if e.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
} else {
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
}
if e.not {
cond = "not " + cond
}
return cond, args, err
}
type roleCond struct {
role string
cond squirrel.Sqlizer
not bool
}
func (e roleCond) ToSql() (string, []any, error) {
var cond string
var args []any
var err error
if e.cond != nil {
cond, args, err = e.cond.ToSql()
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond)
} else {
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role)
}
if e.not {
cond = "not " + cond
}
return cond, args, err
}
func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
if len(values) != 1 {
return "", nil, criteria.FieldInfo{}, false
}
for field, value := range values {
info, ok := criteria.LookupField(field)
return field, value, info, ok
}
return "", nil, criteria.FieldInfo{}, false
}
func sqlFields(values map[string]any) (map[string]any, error) {
fields := make(map[string]any, len(values))
for field, value := range values {
info, ok := criteria.LookupField(field)
if !ok {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if info.IsTag || info.IsRole {
return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
}
sqlField, ok := fieldExpr(info.Name())
if !ok || sqlField == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
fields[sqlField] = value
}
return fields, nil
}
func fieldExpr(name string) (string, bool) {
field, ok := smartPlaylistFields[strings.ToLower(name)]
return field.expr, ok
}
func fieldJoinType(name string) smartPlaylistJoinType {
info, ok := criteria.LookupField(name)
if !ok {
return smartPlaylistJoinNone
}
field, ok := smartPlaylistFields[info.Name()]
if !ok {
return smartPlaylistJoinNone
}
return field.joinType
}
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
var joins smartPlaylistJoinType
_ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
for field := range criteria.Fields(expr) {
joins |= fieldJoinType(field)
}
return nil
})
return joins
}
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
joins := c.ExpressionJoins()
for _, name := range c.Criteria.SortFieldNames() {
joins |= fieldJoinType(name)
}
return joins
}
func (c smartPlaylistCriteria) OrderBy() string {
sortFields := c.Criteria.OrderByFields()
parts := make([]string, 0, len(sortFields))
for _, sf := range sortFields {
mapped, ok := sortExpr(sf.Field)
if !ok {
continue
}
dir := "asc"
if sf.Desc {
dir = "desc"
}
parts = append(parts, mapped+" "+dir)
}
return strings.Join(parts, ", ")
}
func sortExpr(sortField string) (string, bool) {
info, ok := criteria.LookupField(sortField)
if !ok {
return "", false
}
if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
return field.order, true
}
var mapped string
switch {
case info.IsTag:
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
case info.IsRole:
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
default:
field, ok := smartPlaylistFields[info.Name()]
if !ok || field.expr == "" {
return "", false
}
mapped = field.expr
}
if info.Numeric {
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
}
return mapped, true
}

View File

@ -0,0 +1,234 @@
package persistence
import (
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Smart playlist criteria SQL", func() {
BeforeEach(func() {
criteria.AddRoles([]string{"artist", "composer", "producer"})
criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"})
criteria.AddNumericTags([]string{"rate"})
})
DescribeTable("expressions",
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(HaveExactElements(expectedArgs...))
},
Entry("all group",
criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}},
"(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3),
Entry("any group",
criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}},
"(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"),
Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6),
Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"),
Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"),
Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"),
Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
// ReplayGain fields
Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0),
Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0),
Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
// isMissing — tags
Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isMissing — roles
Entry("isMissing role [true]", criteria.IsMissing{"artist": true},
"not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
Entry("isMissing role [false]", criteria.IsMissing{"artist": false},
"exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"),
// isPresent — tags
Entry("isPresent tag [true]", criteria.IsPresent{"genre": true},
"exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
// isPresent — roles
Entry("isPresent role [true]", criteria.IsPresent{"composer": true},
"exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
Entry("isPresent role [false]", criteria.IsPresent{"composer": false},
"not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"),
)
Describe("playlist permissions", func() {
It("allows public or same-owner playlist references for regular users", func() {
sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))"))
Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id"))
})
It("allows all playlist references for admins", func() {
sqlizer, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))"))
Expect(args).To(HaveExactElements("deadbeef-dead-beef"))
})
})
It("builds relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("annotation.play_date > ?"))
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
})
It("builds negated relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)"))
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
})
It("returns an error for unknown fields", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
Expect(err).To(MatchError("invalid field in criteria: unknown"))
})
It("returns an error when isMissing is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
})
It("returns an error when isPresent is used with a regular field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields")))
})
Describe("sort", func() {
It("sorts by regular fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
})
It("sorts by tag fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
})
It("sorts by role fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
})
It("casts numeric tags when sorting", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
})
It("sorts by albumtype alias", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
})
It("sorts by random", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
})
It("sorts by multiple fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
})
It("reverts order when order is desc", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
})
It("ignores invalid sort fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
})
})
It("has SQL mappings for all non-tag/non-role criteria fields", func() {
for _, name := range criteria.AllFieldNames() {
info, ok := criteria.LookupField(name)
Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name)
if info.IsTag || info.IsRole {
continue
}
_, hasSQLField := smartPlaylistFields[info.Name()]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name())
}
})
Describe("joins", func() {
It("excludes sort-only joins from expression joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}
cSQL := newSmartPlaylistCriteria(c)
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
})
It("includes expression-based joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
})
It("detects nested album and artist joins", func() {
c := criteria.Criteria{Expression: criteria.All{
criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}},
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
}}
joins := newSmartPlaylistCriteria(c).RequiredJoins()
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
It("detects join types from sort fields with direction prefixes", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
})
})

View File

@ -0,0 +1,345 @@
package e2e
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
"testing/fstest"
"time"
"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"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSmartPlaylistE2E(t *testing.T) {
tests.Init(t, false)
defer db.Close(t.Context())
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Smart Playlist E2E Suite")
}
type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
var (
ctx context.Context
ds *tests.MockDataStore
lib model.Library
dbFilePath string
snapshotPath string
snapshotTables []string
adminUser = model.User{
ID: "sp-test-user-1",
UserName: "sptestuser",
Name: "SP Test User",
IsAdmin: true,
}
regularUser = model.User{
ID: "sp-test-user-2",
UserName: "spotheruser",
Name: "SP Other User",
IsAdmin: false,
}
)
func buildTestFS() {
abbeyRoad := template(_t{
"albumartist": "The Beatles",
"artist": "The Beatles",
"album": "Abbey Road",
"year": 1969,
"genre": "Rock;Blues",
})
ledZepIV := template(_t{
"albumartist": "Led Zeppelin",
"artist": "Led Zeppelin",
"album": "IV",
"year": 1971,
})
kindOfBlue := template(_t{
"albumartist": "Miles Davis",
"artist": "Miles Davis",
"album": "Kind of Blue",
"year": 1959,
"genre": "Jazz",
"composer": "Miles Davis",
})
nightAtOpera := template(_t{
"albumartist": "Queen",
"artist": "Queen",
"album": "A Night at the Opera",
"year": 1975,
"genre": "Rock",
})
electricLadyland := template(_t{
"albumartist": "Jimi Hendrix",
"artist": "Jimi Hendrix",
"album": "Electric Ladyland",
"year": 1968,
"genre": "Rock;Blues",
})
newsOfWorld := template(_t{
"albumartist": "Queen",
"artist": "Queen",
"album": "News of the World",
"year": 1977,
"genre": "Rock;Pop",
"compilation": "1",
})
fs := storagetest.FakeFS{}
fs.SetFiles(fstest.MapFS{
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together",
_t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
_t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})),
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven",
_t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16})),
"Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog",
_t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16})),
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What",
_t{"bpm": 136})),
"Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody",
_t{"composer": "Freddie Mercury", "bpm": 72})),
"Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower",
_t{"composer": "Bob Dylan", "bpm": 112})),
"Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions",
_t{"composer": "Freddie Mercury", "bpm": 64})),
})
storagetest.Register("fake", &fs)
}
func findMediaFileByTitle(title string) string {
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"media_file.title": title},
})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title)
return mfs[0].ID
}
func evaluateRule(jsonRule string) []string {
titles := evaluateRuleOrderedAs(adminUser, jsonRule)
sort.Strings(titles)
return titles
}
func evaluateRuleOrdered(jsonRule string) []string {
return evaluateRuleOrderedAs(adminUser, jsonRule)
}
func evaluateRuleAs(owner model.User, jsonRule string) []string {
titles := evaluateRuleOrderedAs(owner, jsonRule)
sort.Strings(titles)
return titles
}
func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string {
userCtx := request.WithUser(GinkgoT().Context(), owner)
var rules criteria.Criteria
err := json.Unmarshal([]byte(jsonRule), &rules)
Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule)
pls := &model.Playlist{
Name: "test-smart-playlist",
OwnerID: owner.ID,
Rules: &rules,
}
err = ds.Playlist(userCtx).Put(pls)
Expect(err).ToNot(HaveOccurred())
loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
titles := make([]string, len(loaded.Tracks))
for i, t := range loaded.Tracks {
titles[i] = t.Title
}
return titles
}
func createPlaylist(owner model.User, public bool, titles ...string) string {
pls := &model.Playlist{
Name: "ref-playlist",
OwnerID: owner.ID,
Public: public,
}
for _, title := range titles {
mfID := findMediaFileByTitle(title)
pls.AddMediaFilesByID([]string{mfID})
}
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
return pls.ID
}
func createPublicPlaylist(owner model.User, titles ...string) string {
return createPlaylist(owner, true, titles...)
}
func createPrivatePlaylist(owner model.User, titles ...string) string {
return createPlaylist(owner, false, titles...)
}
func createPublicSmartPlaylist(owner model.User, jsonRule string) string {
return createSmartPlaylist(owner, true, jsonRule)
}
func createPrivateSmartPlaylist(owner model.User, jsonRule string) string {
return createSmartPlaylist(owner, false, jsonRule)
}
func createSmartPlaylist(owner model.User, public bool, jsonRule string) string {
var rules criteria.Criteria
Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed())
pls := &model.Playlist{
Name: "ref-smart-playlist",
OwnerID: owner.ID,
Public: public,
Rules: &rules,
}
Expect(ds.Playlist(ctx).Put(pls)).To(Succeed())
return pls.ID
}
var _ = BeforeSuite(func() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
tmpDir := GinkgoT().TempDir()
dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db")
snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot")
conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
db.Db().SetMaxOpenConns(1)
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
conf.Server.SmartPlaylistRefreshDelay = 0
db.Init(ctx)
initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
userWithPass := adminUser
userWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed())
regularUserWithPass := regularUser
regularUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&regularUserWithPass)).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
loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
regularUser.Libraries = loadedOther.Libraries
ctx = request.WithUser(GinkgoT().Context(), adminUser)
buildTestFS()
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance())
_, err = s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
comeTogetherID := findMediaFileByTitle("Come Together")
Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed())
Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed())
Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed())
Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed())
for range 10 {
Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed())
}
Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed())
rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'")
Expect(err).ToNot(HaveOccurred())
defer rows.Close()
for rows.Next() {
var name string
Expect(rows.Scan(&name)).To(Succeed())
snapshotTables = append(snapshotTables, name)
}
Expect(rows.Err()).ToNot(HaveOccurred())
_, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)")
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(dbFilePath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
})
var _ = AfterSuite(func() {
db.Close(ctx)
})
func restoreDB() {
sqlDB := db.Db()
_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
Expect(err).ToNot(HaveOccurred())
defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }()
_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
Expect(err).ToNot(HaveOccurred())
defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }()
_, err = sqlDB.Exec("BEGIN TRANSACTION")
Expect(err).ToNot(HaveOccurred())
defer func() { _, _ = sqlDB.Exec("ROLLBACK") }()
for _, table := range snapshotTables {
_, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
}
_, err = sqlDB.Exec("COMMIT")
Expect(err).ToNot(HaveOccurred())
}
func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
conf.Server.SmartPlaylistRefreshDelay = 0
restoreDB()
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
}

View File

@ -0,0 +1,374 @@
package e2e
import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sirupsen/logrus"
)
var _ = Describe("Smart Playlists", func() {
BeforeEach(func() {
setupTestDB()
})
Describe("String fields", func() {
It("matches by exact title", func() {
results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`)
Expect(results).To(ConsistOf("Something"))
})
It("matches by title contains", func() {
results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`)
Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions"))
})
It("matches by artist startsWith", func() {
results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
})
It("matches by title isNot", func() {
results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`)
Expect(results).To(ConsistOf("Come Together"))
})
It("matches by artist endsWith", func() {
results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
})
Describe("Numeric fields", func() {
It("matches by year greater than", func() {
results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions"))
})
It("matches by year less than", func() {
results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`)
Expect(results).To(ConsistOf("So What", "All Along the Watchtower"))
})
It("matches by BPM in range", func() {
results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower"))
})
})
Describe("Boolean fields", func() {
It("matches compilations", func() {
results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`)
Expect(results).To(ConsistOf("We Are the Champions"))
})
It("matches non-compilations", func() {
results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower"))
})
})
Describe("File type fields", func() {
It("matches by filetype", func() {
results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
})
})
Describe("Multi-valued tags", func() {
It("matches tracks with Blues genre", func() {
results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`)
Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
})
It("excludes tracks with Rock genre", func() {
results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
It("matches genre contains", func() {
results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven"))
})
It("matches tracks with Pop genre", func() {
results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`)
Expect(results).To(ConsistOf("We Are the Champions"))
})
It("matches genre startsWith", func() {
results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
})
Describe("Participants", func() {
It("matches by exact composer", func() {
results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`)
Expect(results).To(ConsistOf("Something"))
})
It("matches by composer contains", func() {
results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog"))
})
It("matches by composer isNot", func() {
results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower"))
})
It("matches by composer endsWith", func() {
results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions"))
})
})
Describe("Annotations", func() {
It("matches starred tracks", func() {
results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("matches unstarred tracks", func() {
results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`)
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("matches by rating greater than", func() {
results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
})
It("matches by rating greater than or equal via inTheRange", func() {
results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
})
It("matches by play count greater than", func() {
results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`)
Expect(results).To(ConsistOf("Come Together"))
})
It("matches by play count greater than zero", func() {
results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`)
Expect(results).To(ConsistOf("Come Together", "Black Dog"))
})
})
Describe("Negated string operators", func() {
It("matches by title notContains", func() {
results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`)
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody"))
})
})
Describe("Date/time fields", func() {
It("matches dateAdded before a far-future date", func() {
results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("matches lastPlayed inTheLast 1 day", func() {
results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`)
Expect(results).To(ConsistOf("Come Together", "Black Dog"))
})
It("matches lastPlayed notInTheLast (far future)", func() {
results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`)
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("matches dateLoved after a past date", func() {
results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("matches dateRated after a past date", func() {
results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody"))
})
It("matches dateAdded inTheLast 1 day", func() {
results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("resolves recordingdate alias to the date column", func() {
results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
})
Describe("Logic operators", func() {
It("matches with ALL (AND)", func() {
results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`)
Expect(results).To(ConsistOf("Black Dog"))
})
It("matches with ANY (OR)", func() {
results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`)
Expect(results).To(ConsistOf("So What", "We Are the Champions"))
})
It("matches nested all/any", func() {
results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`)
Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower"))
})
})
Describe("Sorting and limits", func() {
It("returns tracks sorted by year descending with limit", func() {
results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`)
Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"}))
})
It("returns tracks sorted by title ascending", func() {
results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`)
Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"}))
})
})
Describe("Combined real-world patterns", func() {
It("matches genre filter with exclusion and year range", func() {
results := evaluateRuleOrdered(`{
"all":[
{"any":[
{"is":{"genre":"Blues"}},
{"is":{"genre":"Folk"}}
]},
{"isNot":{"genre":"Jazz"}},
{"gt":{"year":1965}}
],
"sort":"-year,title"
}`)
Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"}))
})
})
Describe("Playlist operators", func() {
It("matches tracks in a public regular playlist", func() {
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("matches tracks not in a public regular playlist", func() {
refID := createPublicPlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("recursively refreshes a referenced smart playlist owned by the same user", func() {
smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
Expect(results).To(ConsistOf("So What"))
})
It("does not refresh a referenced smart playlist owned by another user", func() {
smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
Expect(results).To(BeEmpty())
})
It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() {
smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`)
smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`)
loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(loadedA.Tracks).To(BeEmpty())
Expect(loadedA.EvaluatedAt).To(BeNil())
loadedB, err := ds.Playlist(ctx).Get(smartBID)
Expect(err).ToNot(HaveOccurred())
Expect(loadedB.EvaluatedAt).To(BeNil())
})
It("matches tracks from a private playlist owned by the same user", func() {
refID := createPrivatePlaylist(regularUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "So What"))
})
It("allows admin-owned smart playlists to reference private playlists owned by other users", func() {
refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody")
results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
})
It("does not match tracks from a private playlist owned by another regular user", func() {
refID := createPrivatePlaylist(adminUser, "Come Together", "So What")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(BeEmpty())
})
It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() {
hook, cleanup := tests.LogHook()
defer cleanup()
refID := createPrivatePlaylist(adminUser, "Come Together")
results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
Expect(hook.LastEntry()).ToNot(BeNil())
Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner"))
Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID))
})
It("matches tracks in a public playlist owned by another user", func() {
refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody")
results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`)
Expect(results).To(ConsistOf("Bohemian Rhapsody"))
})
})
Describe("isMissing/isPresent operators", func() {
It("isMissing finds tracks without grouping tag", func() {
results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("isMissing false finds tracks with grouping tag", func() {
results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something"))
})
It("isPresent finds tracks with grouping tag", func() {
results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something"))
})
It("isPresent false finds tracks without grouping tag", func() {
results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`)
Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
"Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("isMissing returns all tracks for a tag nobody has", func() {
results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("isPresent returns all tracks for a role everyone has", func() {
results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`)
Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog",
"So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
})
It("combines isMissing with other operators", func() {
results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`)
Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
})
})
})

View File

@ -11,10 +11,8 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/pocketbase/dbx"
)
@ -99,8 +97,15 @@ func (r *playlistRepository) Delete(id string) error {
return r.delete(And{Eq{"id": id}, r.userFilter()})
}
func (r *playlistRepository) Put(p *model.Playlist) error {
func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error {
pls := dbPlaylist{Playlist: *p}
if len(cols) > 0 {
if pls.ID == "" {
return errors.New("playlist id is required for partial update")
}
_, err := r.put(pls.ID, pls, cols...)
return err
}
if pls.ID == "" {
pls.CreatedAt = time.Now()
}
@ -202,141 +207,6 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec
Columns(r.tableName+".*", "user.user_name as owner_name")
}
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
return false
}
// Never refresh other users' playlists
usr := loggedUser(r.ctx)
if pls.OwnerID != usr.ID {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
// Remove old tracks
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
_, err := r.executeSQL(del)
if err != nil {
log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Re-populate playlist based on Smart Playlist criteria
rules := *pls.Rules
// If the playlist depends on other playlists, recursively refresh them first
childPlaylistIds := rules.ChildPlaylistIds()
for _, id := range childPlaylistIds {
childPls, err := r.Get(id)
if err != nil {
log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
return false
}
r.refreshSmartPlaylist(childPls)
}
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
requiredJoins := rules.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
// Only include media files from libraries the user has access to
sq = r.applyLibraryFilter(sq, "media_file")
// Resolve percentage-based limit to an absolute number before applying criteria
if rules.IsPercentageLimit() {
// Use only expression-based joins for the COUNT query (sort joins are unnecessary)
exprJoins := rules.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file").
LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
countSq = r.applyLibraryFilter(countSq, "media_file")
countSq = countSq.Where(rules)
var res struct{ Count int64 }
err = r.queryOne(countSq, &res)
if err != nil {
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
return false
}
resolvedLimit := rules.EffectiveLimit(res.Count)
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit)
rules.Limit = resolvedLimit
rules.LimitPercent = 0
}
// Apply the criteria rules
sq = r.addCriteria(sq, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
if err != nil {
log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Update playlist stats
err = r.refreshCounters(pls)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Update when the playlist was last refreshed (for cache purposes)
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
_, err = r.executeSQL(updSql)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
}
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder {
if joins.Has(criteria.JoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.Has(criteria.JoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
return sq
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
sql = sql.Where(c)
if c.Limit > 0 {
sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
}
if order := c.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql
}
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {
ids := make([]string, len(tracks))
for i := range tracks {

View File

@ -1,17 +1,11 @@
package persistence
import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository", func() {
@ -128,379 +122,6 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Context("Smart Playlists", func() {
var rules *criteria.Criteria
BeforeEach(func() {
rules = &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "love"},
},
}
})
Context("valid rules", func() {
Specify("Put/Get", func() {
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
savedPls, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
})
})
Context("invalid rules", func() {
It("fails to Put it in the DB", func() {
rules = &criteria.Criteria{
// This is invalid because "contains" cannot have multiple fields
Expression: criteria.All{
criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
},
}
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
})
})
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("refresh delay has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
// Getting parent with refresh should recursively refresh the nested playlist
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.EvaluatedAt).ToNot(BeNil())
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Parent should have been refreshed (its EvaluatedAt was nil)
Expect(parent.EvaluatedAt).ToNot(BeNil())
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Nested playlist should NOT have been refreshed (still within delay window)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
It("sorts tracks correctly by album (disc and track number)", func() {
By("creating a playlist with multi-disc album tracks in arbitrary order")
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
// Add tracks in intentionally scrambled order
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("retrieving tracks sorted by album")
tracksRepo := repo.Tracks(newPls.ID, false)
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
Expect(err).ToNot(HaveOccurred())
By("verifying tracks are sorted by disc number then track number")
Expect(tracks).To(HaveLen(4))
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
})
It("matches tracks from starred albums using albumLoved", func() {
// albumRadioactivity (ID "103") is starred in test fixtures
// Songs in album 103: 1003, 1004, 1005, 1006
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
})
It("matches tracks from starred artists using artistLoved", func() {
// artistBeatles (ID "3") is starred in test fixtures
// Songs with ArtistID "3": 1001, 1002, 3002
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
})
It("matches tracks with combined album and artist criteria", func() {
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
// artistLoved=true → songs with artist 3 (1001, 1002)
// Using Any: union of both sets
rules := &criteria.Criteria{
Expression: criteria.Any{
criteria.Is{"albumLoved": true},
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
})
It("returns no tracks when no albums/artists match", func() {
// No album has rating 5 in fixtures
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumRating": 5},
},
}
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(BeEmpty())
})
})
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
Describe("Track Deletion and Renumbering", func() {
var testPlaylistID string
@ -573,136 +194,4 @@ var _ = Describe("PlaylistRepository", func() {
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
})
})
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var lib2ID int
var restrictedUserID string
var uniqueLibPath string
BeforeEach(func() {
db := GetDBXBuilder()
// Generate unique IDs for this test run
uniqueSuffix := time.Now().Format("20060102150405.000")
restrictedUserID = "restricted-user-" + uniqueSuffix
uniqueLibPath = "/music/lib2-" + uniqueSuffix
// Create a second library with unique name and path to avoid conflicts with other tests
_, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
Expect(err).ToNot(HaveOccurred())
err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
Expect(err).ToNot(HaveOccurred())
// Create a restricted user with access only to library 1
_, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
Expect(err).ToNot(HaveOccurred())
_, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
Expect(err).ToNot(HaveOccurred())
// Create test media files in each library
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, db)
// Song in library 1 (accessible by restricted user)
songLib1 := model.MediaFile{
ID: "lib1-song",
Title: "Song in Lib1",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib1/song.mp3",
LibraryID: 1,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib1)).To(Succeed())
// Song in library 2 (NOT accessible by restricted user)
songLib2 := model.MediaFile{
ID: "lib2-song",
Title: "Song in Lib2",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib2/song.mp3",
LibraryID: lib2ID,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib2)).To(Succeed())
})
AfterEach(func() {
db := GetDBXBuilder()
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test data
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
_, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
_, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
_, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
})
It("should only include tracks from libraries the user has access to (issue #4738)", func() {
db := GetDBXBuilder()
ctx := log.NewContext(GinkgoT().Context())
// Create the smart playlist as the restricted user
restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
ctx = request.WithUser(ctx, restrictedUser)
restrictedRepo := NewPlaylistRepository(ctx, db)
// Create a smart playlist that matches all songs
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Gt{"playCount": -1}, // Matches everything
},
}
newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
Expect(restrictedRepo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track from library 1 is in the playlist")
var foundLib1Song, foundLib2Song bool
for _, track := range pls.Tracks {
if track.MediaFileID == "lib1-song" {
foundLib1Song = true
}
if track.MediaFileID == "lib2-song" {
foundLib2Song = true
}
}
Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
By("verifying playlist_tracks table only contains the accessible track")
var playlistTracksCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
Expect(err).ToNot(HaveOccurred())
// Count should only include tracks visible to the user (lib1-song)
// The count may include other test songs from library 1, but NOT lib2-song
var lib2TrackCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
Expect(err).ToNot(HaveOccurred())
Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
By("verifying SongCount matches visible tracks")
Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
})
})
})

View File

@ -0,0 +1,203 @@
package persistence
import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated
// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist
// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists
// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and
// configured refresh delay.
// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
usr := loggedUser(r.ctx)
if !r.shouldRefreshSmartPlaylist(pls, usr) {
return false
}
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
if _, err := r.executeSQL(del); err != nil {
log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
if !r.refreshChildPlaylists(pls, rulesSQL) {
return false
}
if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil {
return false
}
sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID)
sq, err := r.addCriteria(sq, rulesSQL)
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
if _, err = r.executeSQL(insSql); err != nil {
log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
if err = r.refreshCounters(pls); err != nil {
log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
if _, err = r.executeSQL(updSql); err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
}
// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated
// time, and ownership.
func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool {
if !pls.IsSmartPlaylist() {
return false
}
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay {
return false
}
if pls.OwnerID != usr.ID {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
return true
}
// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria.
// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort.
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
childPlaylistIds := rulesSQL.ChildPlaylistIds()
if len(childPlaylistIds) == 0 {
return true
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
if err != nil {
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
return false
}
found := make(map[string]struct{}, len(childPlaylists))
for i := range childPlaylists {
found[childPlaylists[i].ID] = struct{}{}
r.refreshSmartPlaylist(&childPlaylists[i])
}
for _, id := range childPlaylistIds {
if _, ok := found[id]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
}
}
return true
}
// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit.
func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error {
if !rulesSQL.IsPercentageLimit() {
return nil
}
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file")
countSq = r.addMediaFileAnnotationJoin(countSq, userID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return err
}
countSq = countSq.Where(cond)
var res struct{ Count int64 }
if err = r.queryOne(countSq, &res); err != nil {
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
return err
}
rulesSQL.ResolveLimit(res.Count)
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit)
return nil
}
// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria,
// including necessary joins for annotations and library filtering.
func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file")
sq = r.addMediaFileAnnotationJoin(sq, userID)
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID)
sq = r.applyLibraryFilter(sq, "media_file")
return sq
}
// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include
// user-specific annotations in the smart playlist criteria evaluation.
func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder {
return sq.LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", userID)
}
// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on
// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation.
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
return sq
}
// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the
// smart playlist criteria.
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
cond, err := cSQL.Where()
if err != nil {
return sql, err
}
sql = sql.Where(cond)
if cSQL.Criteria.Limit > 0 {
sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
}
if order := cSQL.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql, nil
}

View File

@ -0,0 +1,531 @@
package persistence
import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository - Smart Playlists", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
Context("Smart Playlists", func() {
var rules *criteria.Criteria
BeforeEach(func() {
rules = &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "love"},
},
}
})
Context("valid rules", func() {
Specify("Put/Get", func() {
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(newPls.ID) })
savedPls, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
})
})
Context("invalid rules", func() {
It("fails to Put it in the DB", func() {
rules = &criteria.Criteria{
// This is invalid because "contains" cannot have multiple fields
Expression: criteria.All{
criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
},
}
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
})
})
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("refresh delay has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
// Getting parent with refresh should recursively refresh the nested playlist
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.EvaluatedAt).ToNot(BeNil())
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Parent should have been refreshed (its EvaluatedAt was nil)
Expect(parent.EvaluatedAt).ToNot(BeNil())
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Nested playlist should NOT have been refreshed (still within delay window)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
})
})
Describe("Playlist Track Sorting", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
It("sorts tracks correctly by album (disc and track number)", func() {
By("creating a playlist with multi-disc album tracks in arbitrary order")
newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"}
// Add tracks in intentionally scrambled order
newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("retrieving tracks sorted by album")
tracksRepo := repo.Tracks(newPls.ID, false)
tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"})
Expect(err).ToNot(HaveOccurred())
By("verifying tracks are sorted by disc number then track number")
Expect(tracks).To(HaveLen(4))
// Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11
Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1
Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2
Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
Describe("Smart Playlists with Album/Artist Annotation Criteria", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
})
It("matches tracks from starred albums using albumLoved", func() {
// albumRadioactivity (ID "103") is starred in test fixtures
// Songs in album 103: 1003, 1004, 1005, 1006
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006"))
})
It("matches tracks from starred artists using artistLoved", func() {
// artistBeatles (ID "3") is starred in test fixtures
// Songs with ArtistID "3": 1001, 1002, 3002
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "3002"))
})
It("matches tracks with combined album and artist criteria", func() {
// albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006)
// artistLoved=true → songs with artist 3 (1001, 1002)
// Using Any: union of both sets
rules := &criteria.Criteria{
Expression: criteria.Any{
criteria.Is{"albumLoved": true},
criteria.Is{"artistLoved": true},
},
}
newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
trackIDs := make([]string, len(pls.Tracks))
for i, t := range pls.Tracks {
trackIDs[i] = t.MediaFileID
}
Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002"))
})
It("returns no tracks when no albums/artists match", func() {
// No album has rating 5 in fixtures
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Is{"albumRating": 5},
},
}
newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(BeEmpty())
})
})
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var lib2ID int
var restrictedUserID string
var uniqueLibPath string
BeforeEach(func() {
db := GetDBXBuilder()
// Generate unique IDs for this test run
uniqueSuffix := time.Now().Format("20060102150405.000")
restrictedUserID = "restricted-user-" + uniqueSuffix
uniqueLibPath = "/music/lib2-" + uniqueSuffix
// Create a second library with unique name and path to avoid conflicts with other tests
_, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath)
Expect(err).ToNot(HaveOccurred())
err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID)
Expect(err).ToNot(HaveOccurred())
// Create a restricted user with access only to library 1
_, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID)
Expect(err).ToNot(HaveOccurred())
_, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID)
Expect(err).ToNot(HaveOccurred())
// Create test media files in each library
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, db)
// Song in library 1 (accessible by restricted user)
songLib1 := model.MediaFile{
ID: "lib1-song",
Title: "Song in Lib1",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib1/song.mp3",
LibraryID: 1,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib1)).To(Succeed())
// Song in library 2 (NOT accessible by restricted user)
songLib2 := model.MediaFile{
ID: "lib2-song",
Title: "Song in Lib2",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "lib2/song.mp3",
LibraryID: lib2ID,
Participants: model.Participants{},
Tags: model.Tags{},
Lyrics: "[]",
}
Expect(mfRepo.Put(&songLib2)).To(Succeed())
})
AfterEach(func() {
db := GetDBXBuilder()
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test data
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute()
_, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute()
_, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute()
_, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute()
_, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID)
})
It("should only include tracks from libraries the user has access to (issue #4738)", func() {
db := GetDBXBuilder()
ctx := log.NewContext(GinkgoT().Context())
// Create the smart playlist as the restricted user
restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false}
ctx = request.WithUser(ctx, restrictedUser)
restrictedRepo := NewPlaylistRepository(ctx, db)
// Create a smart playlist that matches all songs
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Gt{"playCount": -1}, // Matches everything
},
}
newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules}
Expect(restrictedRepo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track from library 1 is in the playlist")
var foundLib1Song, foundLib2Song bool
for _, track := range pls.Tracks {
if track.MediaFileID == "lib1-song" {
foundLib1Song = true
}
if track.MediaFileID == "lib2-song" {
foundLib2Song = true
}
}
Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist")
Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist")
By("verifying playlist_tracks table only contains the accessible track")
var playlistTracksCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount)
Expect(err).ToNot(HaveOccurred())
// Count should only include tracks visible to the user (lib1-song)
// The count may include other test songs from library 1, but NOT lib2-song
var lib2TrackCount int
err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount)
Expect(err).ToNot(HaveOccurred())
Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks")
By("verifying SongCount matches visible tracks")
Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks")
})
})
})

View File

@ -8,6 +8,7 @@ import (
"unicode/utf8"
. "github.com/Masterminds/squirrel"
"github.com/deluan/sanitize"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
@ -44,24 +45,33 @@ var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`)
// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries).
var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`)
// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word,
// and returns a space-separated string of words that changed after stripping (deduplicated).
// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC".
// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of
// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC)
// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed
// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics —
// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree
// without an explicit transliterated entry here.
func normalizeForFTS(values ...string) string {
seen := make(map[string]struct{})
var result []string
add := func(orig, variant string) {
if variant == "" || variant == orig {
return
}
lower := strings.ToLower(variant)
if _, ok := seen[lower]; ok {
return
}
seen[lower] = struct{}{}
result = append(result, variant)
}
for _, v := range values {
for _, word := range strings.Fields(v) {
stripped := fts5PunctStrip.ReplaceAllString(word, "")
if stripped == "" || stripped == word {
continue
}
lower := strings.ToLower(stripped)
if _, ok := seen[lower]; ok {
continue
}
seen[lower] = struct{}{}
result = append(result, stripped)
transliterated := sanitize.Accents(word)
// Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne.
add(word, fts5PunctStrip.ReplaceAllString(transliterated, ""))
// Accent-only transliteration for words without name-punctuation (Bjørk → Bjork).
add(word, transliterated)
}
}
return strings.Join(result, " ")
@ -158,6 +168,13 @@ func buildFTS5Query(userInput string) string {
result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:]
}
// Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …)
// so the query matches the ASCII variants emitted by normalizeForFTS at index time.
// FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without
// this step queries for words containing these letters can miss. Quoted phrases are
// left untouched so they continue to match the original text in title/artist columns.
result = sanitize.Accents(result)
// Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive:
// AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens)
result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower)

View File

@ -37,7 +37,13 @@ var _ = DescribeTable("buildFTS5Query",
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`),
Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`),
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"),
Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"),
Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"),
Entry("transliterates ø to o", "Øystein", "Oystein*"),
Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"),
Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"),
Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"),
Entry("transliterates ß to ss", "Straße", "Strasse*"),
Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`),
Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`),
@ -75,11 +81,19 @@ var _ = DescribeTable("normalizeForFTS",
Entry("strips dots and concatenates", "REM", "R.E.M."),
Entry("strips slash", "ACDC", "AC/DC"),
Entry("strips hyphen", "Aha", "A-ha"),
Entry("skips unchanged words", "", "The Beatles"),
Entry("skips unchanged ASCII words", "", "The Beatles"),
Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
Entry("strips apostrophe from word", "N", "Guns N' Roses"),
Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
Entry("transliterates ø to o", "Bjork", "Bjørk"),
Entry("transliterates Ø to O", "Oystein", "Øystein"),
Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"),
Entry("transliterates Latin diacritics", "cafe", "café"),
Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"),
Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"),
Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"),
Entry("transliterates ß to ss", "Strasse", "Straße"),
)
var _ = DescribeTable("containsCJK",

View File

@ -1,8 +1,8 @@
# Navidrome Plugin System
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access.
Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access.
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs).
**Essential Extism Resources:**
- [Extism Documentation](https://extism.org/docs/overview) Core concepts and architecture
@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag
- [Capabilities](#capabilities)
- [MetadataAgent](#metadataagent)
- [Scrobbler](#scrobbler)
- [Lyrics](#lyrics)
- [SonicSimilarity](#sonicsimilarity)
- [TaskWorker](#taskworker)
- [Lifecycle](#lifecycle)
- [SchedulerCallback](#schedulercallback)
- [WebSocketCallback](#websocketcallback)
- [Host Services](#host-services)
- [HTTP Requests](#http-requests)
- [HTTP](#http)
- [Scheduler](#scheduler)
- [Cache](#cache)
- [KVStore](#kvstore)
- [Task](#task)
- [WebSocket](#websocket)
- [Library](#library)
- [Artwork](#artwork)
@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing:
1. **`manifest.json`** Plugin metadata (name, author, version, permissions)
2. **`plugin.wasm`** Compiled WebAssembly module with capability functions
### Plugin Package Structure
```
my-plugin.ndp (zip archive)
├── manifest.json # Required: Plugin metadata
└── plugin.wasm # Required: Compiled WebAssembly module
```
### Plugin Naming
Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field:
@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example:
"version": "1.0.0",
"description": "What this plugin does",
"website": "https://example.com",
"config": {
"schema": { ... },
"uiSchema": { ... }
},
"permissions": {
"http": {
"reason": "Fetch metadata from external API",
@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example:
**Required fields:** `name`, `author`, `version`
**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
#### Config Definition
The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI:
```json
{
"config": {
"schema": {
"type": "object",
"properties": {
"api_key": { "type": "string", "title": "API Key" },
"max_retries": { "type": "integer", "default": 3 }
},
"required": ["api_key"]
},
"uiSchema": {
"api_key": { "ui:widget": "password" }
}
}
}
```
#### Experimental Features
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re
```json
{
"name": "Threaded Plugin",
"author": "Author Name",
"version": "1.0.0",
"experimental": {
"threads": {
"reason": "Required for concurrent audio processing"
@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re
## Capabilities
Capabilities define what your plugin can do. They're automatically detected based on which functions you export.
Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities.
### MetadataAgent
Provides artist and album metadata. Export one or more of these functions:
Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
| Function | Input | Output | Description |
|---------------------------|----------------------------|----------------------------------|----------------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
**Example:**
```go
type ArtistInput struct {
ID string `json:"id"`
Name string `json:"name"`
MBID string `json:"mbid,omitempty"`
}
type BiographyOutput struct {
Biography string `json:"biography"`
}
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
// Fetch biography from your data source...
output := BiographyOutput{Biography: "Artist biography..."}
pdk.OutputJSON(output)
return 0
}
```
| Function | Input | Output | Description |
|-----------------------------------|----------------------------|----------------------------------|--------------------------|
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL |
| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography |
| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists |
| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images |
| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs |
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track |
| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album |
| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist |
To use the plugin as a metadata agent, add it to your config:
@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config:
Agents = "lastfm,spotify,my-plugin"
```
**Example (using Go PDK package):**
```go
package main
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
type myPlugin struct{}
func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) {
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
}
func init() { metadata.Register(&myPlugin{}) }
func main() {}
```
**Example (raw wasmexport):**
```go
//go:wasmexport nd_get_artist_biography
func ndGetArtistBiography() int32 {
var input ArtistInput
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return 1
}
pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."})
return 0
}
```
### Scrobbler
Integrates with external scrobbling services. Export one or more of these functions:
Integrates with external scrobbling services. All three methods are **required**.
| Function | Input | Output | Description |
|------------------------------|-----------------------|----------------|-----------------------------|
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
| Function | Input | Output | Description |
|------------------------------|-----------------------|--------|-----------------------------|
| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized |
| `nd_scrobbler_now_playing` | See below | (none) | Send now playing |
| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble |
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes.
> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration.
**Manifest permission:**
@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
// Return error using predefined constants
return scrobbler.ScrobblerErrorNotAuthorized
return scrobbler.ScrobblerErrorRetryLater
return scrobbler.ScrobblerErrorUnrecoverable
```
### Lyrics
Provides lyrics for tracks. The single method is **required**.
| Function | Input | Output | Description |
|-------------------------|-------------------------------|------------------------------------|-----------------|
| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics |
Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages.
### SonicSimilarity
Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**.
| Function | Input | Output | Description |
|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------|
| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks |
| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs |
Each match contains a `song` reference and a `similarity` score (float64, 0.01.0).
### TaskWorker
Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work.
| Function | Input | Output | Description |
|---------------------|---------------------------------------------|---------|----------------------|
| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task|
The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success.
### Lifecycle
Optional initialization callback. Export this function to run code when your plugin loads:
Optional initialization callback. Called once after the plugin fully loads.
| Function | Input | Output | Description |
|--------------|-------|------------|--------------------------------|
| `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads |
Useful for initializing connections, scheduling recurring tasks, etc.
Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading.
### SchedulerCallback
Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service.
| Function | Input | Output | Description |
|---------------------------|----------------------------------------------|--------|-----------------------------|
| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event |
### WebSocketCallback
Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service.
| Function | Input | Description |
|----------------------------------|---------------------------------|----------------------------------|
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
---
## Host Services
Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest.
Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest.
### HTTP Requests
### Go PDK Setup
Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests.
All host service examples below use the generated Go SDK. Add this to your `go.mod`:
```
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
```
Then import:
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
```
### HTTP
Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control.
**Manifest permission:**
@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis
}
```
**Host functions:**
| Function | Parameters | Returns |
|-------------|----------------------------------------------------------|----------------------------------|
| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` |
**Usage:**
```go
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data")
req.SetHeader("Authorization", "Bearer " + apiKey)
resp := req.Send()
if resp.Status() == 200 {
data := resp.Body()
// Process response...
resp, err := host.HTTPSend(host.HTTPRequest{
Method: "GET",
URL: "https://api.example.com/data",
Headers: map[string]string{"Authorization": "Bearer " + apiKey},
})
if resp.StatusCode == 200 {
// Process resp.Body
}
```
### Scheduler
Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events.
Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events.
**Manifest permission:**
@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call
| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback |
| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task |
**Callback function:**
**Usage:**
```go
type SchedulerCallbackInput struct {
ScheduleID string `json:"scheduleId"`
Payload string `json:"payload"`
IsRecurring bool `json:"isRecurring"`
}
//go:wasmexport nd_scheduler_callback
func ndSchedulerCallback() int32 {
var input SchedulerCallbackInput
pdk.InputJSON(&input)
// Handle the scheduled task based on payload
pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID)
return 0
}
```
**Scheduling tasks (using generated SDK):**
Add the generated SDK to your `go.mod`:
```
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
```
Then import and use:
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Schedule one-time task in 60 seconds
scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "")
@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID)
### Cache
Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace.
In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart.
**Manifest permission:**
@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own
**TTL:** Pass `0` for the default (24 hours), or specify seconds.
**Usage (with generated SDK):**
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Cache a value for 1 hour
host.CacheSetString("api-response", responseData, 3600)
// Retrieve (check Exists before using Value)
result, err := host.CacheGetString("api-response")
if result.Exists {
data := result.Value
// Retrieve (returns value, exists, error)
value, exists, err := host.CacheGetString("api-response")
if exists {
// Use value
}
```
> **Note:** Cache is in-memory only and cleared on server restart.
### KVStore
Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database.
Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
**Manifest permission:**
@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its
}
```
**Permission options:**
- `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB
**Key constraints:** Maximum 256 bytes, must be valid UTF-8.
**Host functions:**
| Function | Parameters | Description |
|--------------------------|--------------|-----------------------------------|
| `kvstore_set` | `key, value` | Store a byte value |
| `kvstore_get` | `key` | Retrieve a byte value |
| `kvstore_delete` | `key` | Delete a value |
| `kvstore_has` | `key` | Check if key exists |
| `kvstore_list` | `prefix` | List keys matching prefix |
| `kvstore_getstorageused` | - | Get current storage usage (bytes) |
| Function | Parameters | Description |
|-----------------------------|--------------------------|-----------------------------------|
| `kvstore_set` | `key, value` | Store a byte value |
| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration |
| `kvstore_get` | `key` | Retrieve a byte value |
| `kvstore_getmany` | `keys` | Retrieve multiple values at once |
| `kvstore_has` | `key` | Check if key exists |
| `kvstore_list` | `prefix` | List keys matching prefix |
| `kvstore_delete` | `key` | Delete a value |
| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix |
| `kvstore_getstorageused` | | Get current storage usage (bytes) |
**Key constraints:**
- Maximum key length: 256 bytes
- Keys must be valid UTF-8 strings
**Usage (with generated SDK):**
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup):
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Store a value (as raw bytes)
token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`)
_, err := host.KVStoreSet("oauth:spotify", token)
host.KVStoreSet("oauth:spotify", token)
// Store with TTL (auto-expires after 1 hour)
host.KVStoreSetWithTTL("session:abc", sessionData, 3600)
// Retrieve a value
result, err := host.KVStoreGet("oauth:spotify")
if result.Exists {
value, exists, err := host.KVStoreGet("oauth:spotify")
if exists {
var tokenData map[string]string
json.Unmarshal(result.Value, &tokenData)
json.Unmarshal(value, &tokenData)
}
// List all keys with prefix
keysResult, err := host.KVStoreList("user:")
for _, key := range keysResult.Keys {
// Process each key
}
// Batch retrieve
results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"})
// List and delete by prefix
keys, err := host.KVStoreList("user:")
host.KVStoreDeleteByPrefix("user:")
// Check storage usage
usageResult, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usageResult.Bytes)
// Delete a value
host.KVStoreDelete("oauth:spotify")
usage, err := host.KVStoreGetStorageUsed()
fmt.Printf("Using %d bytes\n", usage)
```
> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`.
### Task
Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function.
**Manifest permission:**
```json
{
"permissions": {
"taskqueue": {
"reason": "Process audio analysis in the background",
"maxConcurrency": 2
}
}
}
```
**Host functions:**
| Function | Parameters | Description |
|---------------------|---------------------------------------------------|----------------------------|
| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue |
| `task_enqueue` | `queueName, payload` | Add a task to the queue |
| `task_get` | `taskID` | Get task status and result |
| `task_cancel` | `taskID` | Cancel a pending task |
| `task_clearqueue` | `queueName` | Remove all tasks from queue|
**Usage:**
```go
// Create a queue with retry configuration
host.TaskCreateQueue("analysis", host.QueueConfig{
Concurrency: 2,
MaxRetries: 3,
BackoffMs: 1000,
})
// Enqueue a task
taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`))
// Check task status
info, err := host.TaskGet(taskID)
fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt)
```
### WebSocket
Establish persistent WebSocket connections to external services.
Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events.
**Manifest permission:**
@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services.
**Host functions:**
| Function | Parameters | Description |
|------------------------|---------------------------------|-------------------|
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
| `websocket_sendtext` | `connectionId, message` | Send text message |
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
| `websocket_close` | `connectionId, code?, reason?` | Close connection |
| Function | Parameters | Description |
|----------------------------|---------------------------------|-------------------|
| `websocket_connect` | `url, headers?, connectionId?` | Open a connection |
| `websocket_sendtext` | `connectionId, message` | Send text message |
| `websocket_sendbinary` | `connectionId, data` | Send binary data |
| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection |
**Callback functions (export these to receive events):**
**Usage:**
| Function | Input | Description |
|----------------------------------|---------------------------------|----------------------------------|
| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received |
| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) |
| `nd_websocket_on_error` | `{connectionId, error}` | Connection error |
| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed |
```go
connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "")
host.WebSocketSendText(connID, `{"op": 1, "d": null}`)
host.WebSocketCloseConnection(connID, 1000, "done")
```
### Library
@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via
```go
import "os"
// Read a file from library 1
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
// List directory contents
entries, err := os.ReadDir("/libraries/1/Artist")
```
> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem.
> **Security:** Filesystem access is read-only and restricted to configured library paths only.
**Usage (with generated SDK):**
Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK:
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Get a specific library
resp, err := host.LibraryGetLibrary(1)
if err != nil {
// Handle error
}
library := resp.Result
library, err := host.LibraryGetLibrary(1)
fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs)
// Get all libraries
resp, err := host.LibraryGetAllLibraries()
for _, lib := range resp.Result {
// lib is of type host.Library
libraries, err := host.LibraryGetAllLibraries()
for _, lib := range libraries {
fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
}
```
@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists).
| `artwork_gettrackurl` | `id, size` | Artwork URL |
| `artwork_getplaylisturl` | `id, size` | Artwork URL |
**Usage:**
```go
url, err := host.ArtworkGetAlbumUrl("album-id", 300)
```
### SubsonicAPI
Call Navidrome's Subsonic API internally (no network round-trip).
@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip).
}
```
> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API.
> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI.
**Host function:**
**Host functions:**
| Function | Parameters | Returns |
|--------------------|------------|---------------|
| `subsonicapi_call` | `uri` | JSON response |
| Function | Parameters | Returns |
|-----------------------|------------|--------------------------------|
| `subsonicapi_call` | `uri` | JSON response string |
| `subsonicapi_callraw` | `uri` | Content type + binary response |
**Usage:**
```go
// The URI must include the 'u' parameter with the username
response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
// JSON response
response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username")
// Binary response (e.g., cover art, streams)
contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username")
```
### Config
Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings).
Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys useful for discovering dynamic configuration.
> **Note:** This service is always available and does not require a manifest permission.
@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh
| `config_getint` | `key` | `value, exists` |
| `config_keys` | `prefix` | Array of matching key names |
**Usage (with generated SDK):**
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Get a string configuration value
// Get a configuration value
value, exists := host.ConfigGet("api_key")
if exists {
// Use the value
}
// Get an integer configuration value
count, exists := host.ConfigGetInt("max_retries")
// List all keys with a prefix (useful for user-specific config)
keys := host.ConfigKeys("user:")
for _, key := range keys {
// key might be "user:john", "user:jane", etc.
}
// List all configuration keys
allKeys := host.ConfigKeys("")
@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("")
### Users
Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information.
Access user information for the users that the plugin has been granted access to.
**Manifest permission:**
@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to
}
```
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways:
**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access:
1. **Allow all users** Enable the "Allow all users" toggle in the plugin settings
2. **Select specific users** Choose individual users from the user list
@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled.
| Function | Parameters | Returns |
|------------------|------------|-----------------------|
| `users_getusers` | | Array of User objects |
| `users_getadmins`| | Array of admin Users |
**User object fields:**
@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled.
> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins.
**Usage (with generated SDK):**
**Usage:**
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
// Get all users the plugin has access to
users, err := host.UsersGetUsers()
if err != nil {
pdk.Log(pdk.LogError, "Failed to get users: " + err.Error())
return
}
for _, user := range users {
pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")")
if user.IsAdmin {
pdk.Log(pdk.LogInfo, " - Administrator")
}
}
```
**Rust example:**
```rust
use nd_pdk_host::users::get_users;
let users = get_users()?;
for user in users {
println!("User: {} ({})", user.user_name, user.name);
}
```
**Python example:**
```python
from host.nd_host_users import users_get_users
users = users_get_users()
for user in users:
print(f"User: {user['userName']} ({user['name']})")
admins, err := host.UsersGetAdmins()
```
---
@ -834,20 +898,20 @@ if !ok {
}
```
For more advanced access (listing keys, integer values), use the [Config](#config) host service.
---
## Building Plugins
### Supported Languages
Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details.
Plugins can be written in any language that Extism supports. We recommend:
We recommend:
- **Go** Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk)
- **Rust** Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk)
- **Python** Experimental support via [extism-py](https://github.com/extism/python-pdk)
- **TypeScript** Experimental support via [extism-js](https://github.com/extism/js-pdk)
- **Go** Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support.
- **Rust** Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk).
- **Python** Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages.
- **TypeScript** Experimental support via [extism-js](https://github.com/extism/js-pdk).
### Go with TinyGo (Recommended)
@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm
#### Using Go PDK Packages
Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern:
```go
package main
import (
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
)
import "github.com/navidrome/navidrome/plugins/pdk/go/metadata"
type myPlugin struct{}
@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A
return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil
}
func init() {
metadata.Register(&myPlugin{})
}
func init() { metadata.Register(&myPlugin{}) }
func main() {}
```
@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0
replace github.com/navidrome/navidrome => ../../..
```
Available capability packages:
**Available capability packages:**
| Package | Import Path | Description |
|-------------|----------------------------|--------------------------------------|
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) |
| Package | Import Path | Description |
|-------------------|--------------------------------------|--------------------------------------|
| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers |
| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services |
| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers |
| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery |
| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing |
| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization |
| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks |
| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers |
| `host` | `plugins/pdk/go/host` | Host service SDK (all services) |
See the example plugins in [examples/](examples/) for complete usage patterns.
@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm
#### Using Rust PDK
The Rust PDK provides generated type-safe wrappers for both capabilities and host services:
```toml
# Cargo.toml
[dependencies]
@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports
```rust
use nd_pdk::host::{cache, scheduler, library};
// Cache a value for 1 hour
cache::set_string("my_key", "my_value", 3600)?;
// Schedule a recurring task
scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
// Access library metadata
let libs = library::get_all_libraries()?;
```
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples.
See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation.
### Python (with extism-py)
@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py
zip -j my-plugin.ndp manifest.json plugin.wasm
```
**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations).
### Using XTP CLI (Scaffolding)
Bootstrap a new plugin from a schema:
@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm
See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples.
### Using Host Service SDKs
Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`.
**For Go plugins:** Import the SDK as a Go module:
```go
import "github.com/navidrome/navidrome/plugins/pdk/go/host"
```
Add to your `go.mod`:
```
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
```
See [pdk/go/README.md](pdk/go/README.md) for detailed documentation.
**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations).
**Recommendations:**
- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem.
- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety.
- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages.
---
## Examples
See [examples/](examples/) for complete working plugins:
| Plugin | Language | Capabilities | Host Services | Description |
|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------|
| [minimal](examples/minimal/) | Go | MetadataAgent | | Basic structure example |
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
| Plugin | Language | Capabilities | Host Services | Description |
|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------|
| [minimal](examples/minimal/) | Go | MetadataAgent | | Basic structure example |
| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration |
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
---
## Security
Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime:
1. **Host Allowlisting** Only explicitly allowed hosts are accessible via HTTP/WebSocket
2. **Limited File System** Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only
2. **Limited File System** Read-only access to library directories, only when explicitly granted the `library.filesystem` permission
3. **No Network Listeners** Plugins cannot bind ports
4. **Config Isolation** Plugins only receive their own config section
5. **Memory Limits** Controlled by the WebAssembly runtime
6. **User-Scoped Authorization** Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features.
6. **User-Scoped Authorization** Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration
7. **Users Permission** Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed
---
## Runtime Management
@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.
With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive.
If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes.
If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes.
### Enabling/Disabling Plugins
@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis
- **In-flight requests** When reloading, existing requests complete before the new version takes over
- **Config changes** Changes to the plugin configuration in the UI are applied immediately
- **Cache persistence** The in-memory cache is cleared when a plugin is unloaded
- **Cache persistence** The in-memory cache is cleared when a plugin is unloaded

View File

@ -102,6 +102,12 @@ components:
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
libraryId:
type: integer
format: int32
description: |-
LibraryID is the ID of the library the track belongs to.
Only included if the plugin has library permission with filesystem access for the track's library.
path:
type: string
description: |-

View File

@ -68,6 +68,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -128,6 +128,12 @@ components:
mbzReleaseTrackId:
type: string
description: MBZReleaseTrackID is the MusicBrainz release track ID.
libraryId:
type: integer
format: int32
description: |-
LibraryID is the ID of the library the track belongs to.
Only included if the plugin has library permission with filesystem access for the track's library.
path:
type: string
description: |-

View File

@ -0,0 +1,32 @@
package capabilities
// SonicSimilarity provides audio-similarity based track discovery.
//
//nd:capability name=sonicsimilarity required=true
type SonicSimilarity interface {
//nd:export name=nd_get_sonic_similar_tracks
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
//nd:export name=nd_find_sonic_path
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
}
type GetSonicSimilarTracksRequest struct {
Song SongRef `json:"song"`
Count int32 `json:"count"`
}
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
EndSong SongRef `json:"endSong"`
Count int32 `json:"count"`
}
type SonicSimilarityResponse struct {
Matches []SonicMatch `json:"matches"`
}
type SonicMatch struct {
Song SongRef `json:"song"`
Similarity float64 `json:"similarity"`
}

View File

@ -0,0 +1,92 @@
version: v1-draft
exports:
nd_get_sonic_similar_tracks:
input:
$ref: '#/components/schemas/GetSonicSimilarTracksRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SonicSimilarityResponse'
contentType: application/json
nd_find_sonic_path:
input:
$ref: '#/components/schemas/FindSonicPathRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SonicSimilarityResponse'
contentType: application/json
components:
schemas:
FindSonicPathRequest:
properties:
startSong:
$ref: '#/components/schemas/SongRef'
endSong:
$ref: '#/components/schemas/SongRef'
count:
type: integer
format: int32
required:
- startSong
- endSong
- count
GetSonicSimilarTracksRequest:
properties:
song:
$ref: '#/components/schemas/SongRef'
count:
type: integer
format: int32
required:
- song
- count
SongRef:
description: SongRef is a reference to a song with metadata for matching.
properties:
id:
type: string
description: ID is the internal Navidrome mediafile ID (if known).
name:
type: string
description: Name is the song name.
mbid:
type: string
description: MBID is the MusicBrainz ID for the song.
isrc:
type: string
description: ISRC is the International Standard Recording Code for the song.
artist:
type: string
description: Artist is the artist name.
artistMbid:
type: string
description: ArtistMBID is the MusicBrainz artist ID.
album:
type: string
description: Album is the album name.
albumMbid:
type: string
description: AlbumMBID is the MusicBrainz release ID.
duration:
type: number
format: float
description: Duration is the song duration in seconds.
required:
- name
SonicMatch:
properties:
song:
$ref: '#/components/schemas/SongRef'
similarity:
type: number
format: float
required:
- song
- similarity
SonicSimilarityResponse:
properties:
matches:
type: array
items:
$ref: '#/components/schemas/SonicMatch'
required:
- matches

View File

@ -21,6 +21,10 @@ func init() {
)
}
func newLyricsPlugin(p *plugin) *LyricsPlugin {
return &LyricsPlugin{name: p.name, plugin: p}
}
// LyricsPlugin adapts a WASM plugin with the Lyrics capability.
type LyricsPlugin struct {
name string

View File

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string {
return names
}
// LoadMediaAgent loads and returns a media agent plugin by name.
// Returns false if the plugin is not found or doesn't have the MetadataAgent capability.
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) {
return nil, false
}
// Create a new metadata agent adapter for this plugin
return &MetadataAgent{
name: plugin.name,
plugin: plugin,
}, true
return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent)
}
// LoadScrobbler loads and returns a scrobbler plugin by name.
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) {
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
return &ScrobblerPlugin{
name: plugin.name,
plugin: plugin,
allowedUserIDs: plugin.allowedUserIDs,
allUsers: plugin.allUsers,
userIDMap: userIDMap,
}, true
return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin)
}
// LoadLyricsProvider loads and returns a lyrics provider plugin by name.
func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin)
}
func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) {
return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin)
}
func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) {
m.mu.RLock()
plugin, ok := m.plugins[name]
p, ok := m.plugins[name]
m.mu.RUnlock()
if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) {
return nil, false
var zero T
if !ok || !hasCapability(p.capabilities, cap) {
return zero, false
}
return &LyricsPlugin{
name: plugin.name,
plugin: plugin,
}, true
return newAdapter(p), true
}
// PluginInfo contains basic information about a plugin for metrics/insights.

View File

@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
"github.com/navidrome/navidrome/utils/slice"
)
// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata.
@ -44,6 +45,10 @@ func init() {
)
}
func newMetadataAgent(p *plugin) *MetadataAgent {
return &MetadataAgent{name: p.name, plugin: p}
}
// MetadataAgent is an adapter that wraps an Extism plugin and implements
// the agents interfaces for metadata retrieval.
type MetadataAgent struct {
@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
}
// songRefToAgentSong converts a single SongRef to agents.Song
func songRefToAgentSong(s capabilities.SongRef) agents.Song {
return agents.Song{
ID: s.ID,
Name: s.Name,
MBID: s.MBID,
ISRC: s.ISRC,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),
}
}
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
songs := make([]agents.Song, len(refs))
for i, s := range refs {
songs[i] = agents.Song{
ID: s.ID,
Name: s.Name,
MBID: s.MBID,
ISRC: s.ISRC,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),
}
}
return songs
return slice.Map(refs, songRefToAgentSong)
}
// Verify interface implementations at compile time

View File

@ -68,6 +68,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -65,6 +65,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -92,6 +92,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -89,6 +89,9 @@ type TrackInfo struct {
MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
// MBZReleaseTrackID is the MusicBrainz release track ID.
MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
// LibraryID is the ID of the library the track belongs to.
// Only included if the plugin has library permission with filesystem access for the track's library.
LibraryID int32 `json:"libraryId,omitempty"`
// Path is the full path to the track file, relative to the library root.
// Only included if the plugin has library permission with filesystem access for the track's library.
Path string `json:"path,omitempty"`

View File

@ -0,0 +1,136 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains export wrappers for the SonicSimilarity capability.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package sonicsimilarity
import (
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
EndSong SongRef `json:"endSong"`
Count int32 `json:"count"`
}
// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
type GetSonicSimilarTracksRequest struct {
Song SongRef `json:"song"`
Count int32 `json:"count"`
}
// SongRef is a reference to a song with metadata for matching.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// ISRC is the International Standard Recording Code for the song.
ISRC string `json:"isrc,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// SonicMatch represents the SonicMatch data structure.
type SonicMatch struct {
Song SongRef `json:"song"`
Similarity float64 `json:"similarity"`
}
// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
type SonicSimilarityResponse struct {
Matches []SonicMatch `json:"matches"`
}
// SonicSimilarity requires all methods to be implemented.
// SonicSimilarity provides audio-similarity based track discovery.
type SonicSimilarity interface {
// GetSonicSimilarTracks
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
// FindSonicPath
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error)
} // Internal implementation holders
var (
sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error)
)
// Register registers a sonicsimilarity implementation.
// All methods are required.
func Register(impl SonicSimilarity) {
sonicSimilarTracksImpl = impl.GetSonicSimilarTracks
findSonicPathImpl = impl.FindSonicPath
}
// 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_get_sonic_similar_tracks
func _NdGetSonicSimilarTracks() int32 {
if sonicSimilarTracksImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input GetSonicSimilarTracksRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := sonicSimilarTracksImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}
//go:wasmexport nd_find_sonic_path
func _NdFindSonicPath() int32 {
if findSonicPathImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input FindSonicPathRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := findSonicPathImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}

View File

@ -0,0 +1,71 @@
// 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 sonicsimilarity
// FindSonicPathRequest represents the FindSonicPathRequest data structure.
type FindSonicPathRequest struct {
StartSong SongRef `json:"startSong"`
EndSong SongRef `json:"endSong"`
Count int32 `json:"count"`
}
// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
type GetSonicSimilarTracksRequest struct {
Song SongRef `json:"song"`
Count int32 `json:"count"`
}
// SongRef is a reference to a song with metadata for matching.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
// Name is the song name.
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// ISRC is the International Standard Recording Code for the song.
ISRC string `json:"isrc,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// SonicMatch represents the SonicMatch data structure.
type SonicMatch struct {
Song SongRef `json:"song"`
Similarity float64 `json:"similarity"`
}
// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
type SonicSimilarityResponse struct {
Matches []SonicMatch `json:"matches"`
}
// SonicSimilarity requires all methods to be implemented.
// SonicSimilarity provides audio-similarity based track discovery.
type SonicSimilarity interface {
// GetSonicSimilarTracks
GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error)
// FindSonicPath
FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, 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(_ SonicSimilarity) {}

View File

@ -10,5 +10,6 @@ pub mod lyrics;
pub mod metadata;
pub mod scheduler;
pub mod scrobbler;
pub mod sonicsimilarity;
pub mod taskworker;
pub mod websocket;

View File

@ -102,6 +102,10 @@ pub struct TrackInfo {
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
/// LibraryID is the ID of the library the track belongs to.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "is_zero_i32")]
pub library_id: i32,
/// Path is the full path to the track file, relative to the library root.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "String::is_empty")]

View File

@ -122,6 +122,10 @@ pub struct TrackInfo {
/// MBZReleaseTrackID is the MusicBrainz release track ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbz_release_track_id: String,
/// LibraryID is the ID of the library the track belongs to.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "is_zero_i32")]
pub library_id: i32,
/// Path is the full path to the track file, relative to the library root.
/// Only included if the plugin has library permission with filesystem access for the track's library.
#[serde(default, skip_serializing_if = "String::is_empty")]

View File

@ -0,0 +1,141 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains export wrappers for the SonicSimilarity capability.
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// FindSonicPathRequest represents the FindSonicPathRequest data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FindSonicPathRequest {
#[serde(default)]
pub start_song: SongRef,
#[serde(default)]
pub end_song: SongRef,
#[serde(default)]
pub count: i32,
}
/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSonicSimilarTracksRequest {
#[serde(default)]
pub song: SongRef,
#[serde(default)]
pub count: i32,
}
/// SongRef is a reference to a song with metadata for matching.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SongRef {
/// ID is the internal Navidrome mediafile ID (if known).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
/// Name is the song name.
#[serde(default)]
pub name: String,
/// MBID is the MusicBrainz ID for the song.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
/// ISRC is the International Standard Recording Code for the song.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub isrc: String,
/// Artist is the artist name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist: String,
/// ArtistMBID is the MusicBrainz artist ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist_mbid: String,
/// Album is the album name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album: String,
/// AlbumMBID is the MusicBrainz release ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album_mbid: String,
/// Duration is the song duration in seconds.
#[serde(default, skip_serializing_if = "is_zero_f32")]
pub duration: f32,
}
/// SonicMatch represents the SonicMatch data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SonicMatch {
#[serde(default)]
pub song: SongRef,
#[serde(default)]
pub similarity: f64,
}
/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SonicSimilarityResponse {
#[serde(default)]
pub matches: Vec<SonicMatch>,
}
/// 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() }
}
}
/// SonicSimilarity requires all methods to be implemented.
/// SonicSimilarity provides audio-similarity based track discovery.
pub trait SonicSimilarity {
/// GetSonicSimilarTracks
fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result<SonicSimilarityResponse, Error>;
/// FindSonicPath
fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result<SonicSimilarityResponse, Error>;
}
/// Register all exports for the SonicSimilarity capability.
/// This macro generates the WASM export functions for all trait methods.
#[macro_export]
macro_rules! register_sonicsimilarity {
($plugin_type:ty) => {
#[extism_pdk::plugin_fn]
pub fn nd_get_sonic_similar_tracks(
req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
#[extism_pdk::plugin_fn]
pub fn nd_find_sonic_path(
req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}

View File

@ -30,6 +30,20 @@ func init() {
)
}
func newScrobblerPlugin(p *plugin) *ScrobblerPlugin {
userIDMap := make(map[string]struct{})
for _, id := range p.allowedUserIDs {
userIDMap[id] = struct{}{}
}
return &ScrobblerPlugin{
name: p.name,
plugin: p,
allowedUserIDs: p.allowedUserIDs,
allUsers: p.allUsers,
userIDMap: userIDMap,
}
}
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
// the scrobbler.Scrobbler interface for scrobbling to external services.
type ScrobblerPlugin struct {
@ -130,6 +144,7 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo
MBZReleaseTrackID: mf.MbzReleaseTrackID,
}
if p.hasLibraryFilesystemAccess(mf.LibraryID) {
ti.LibraryID = int32(mf.LibraryID)
ti.Path = mf.Path
}
return ti

View File

@ -259,19 +259,25 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() {
},
}
It("includes Path when the plugin has filesystem access to the track's library", func() {
It("includes LibraryID and Path when the plugin has filesystem access to the track's library", func() {
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)}
Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac"))
ti := mediaFileToTrackInfo(p, track)
Expect(ti.LibraryID).To(Equal(int32(1)))
Expect(ti.Path).To(Equal("/music/test.flac"))
})
It("omits Path when the plugin lacks filesystem permission", func() {
It("omits LibraryID and Path when the plugin lacks filesystem permission", func() {
p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)}
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
ti := mediaFileToTrackInfo(p, track)
Expect(ti.LibraryID).To(BeZero())
Expect(ti.Path).To(BeEmpty())
})
It("omits Path when the track's library is not in the allowed set", func() {
It("omits LibraryID and Path when the track's library is not in the allowed set", func() {
p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)}
Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty())
ti := mediaFileToTrackInfo(p, track)
Expect(ti.LibraryID).To(BeZero())
Expect(ti.Path).To(BeEmpty())
})
})
})

View File

@ -0,0 +1,92 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/capabilities"
)
const CapabilitySonicSimilarity Capability = "SonicSimilarity"
const (
FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks"
FuncFindSonicPath = "nd_find_sonic_path"
)
func init() {
registerCapability(
CapabilitySonicSimilarity,
FuncGetSonicSimilarTracks,
FuncFindSonicPath,
)
}
func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin {
return &SonicSimilarityPlugin{name: p.name, plugin: p}
}
type SonicSimilarityPlugin struct {
name string
plugin *plugin
}
func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
req := capabilities.GetSonicSimilarTracksRequest{
Song: mediaFileToSongRef(mf),
Count: int32(count),
}
resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse](
ctx, a.plugin, FuncGetSonicSimilarTracks, req,
)
if err != nil {
return nil, err
}
return sonicMatchesToSimilarResults(resp.Matches), nil
}
func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
req := capabilities.FindSonicPathRequest{
StartSong: mediaFileToSongRef(startMf),
EndSong: mediaFileToSongRef(endMf),
Count: int32(count),
}
resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse](
ctx, a.plugin, FuncFindSonicPath, req,
)
if err != nil {
return nil, err
}
return sonicMatchesToSimilarResults(resp.Matches), nil
}
func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef {
ref := capabilities.SongRef{
ID: mf.ID,
Name: mf.Title,
MBID: mf.MbzRecordingID,
Artist: mf.Artist,
ArtistMBID: mf.MbzArtistID,
Album: mf.Album,
AlbumMBID: mf.MbzAlbumID,
Duration: mf.Duration,
}
if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 {
ref.ISRC = isrcs[0]
}
return ref
}
func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult {
results := make([]sonic.SimilarResult, len(matches))
for i, m := range matches {
results[i] = sonic.SimilarResult{
Song: songRefToAgentSong(m.Song),
Similarity: m.Similarity,
}
}
return results
}
var _ sonic.Provider = (*SonicSimilarityPlugin)(nil)

View File

@ -0,0 +1,110 @@
//go:build !windows
package plugins
import (
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SonicSimilarityPlugin", Ordered, func() {
var (
manager *Manager
provider sonic.Provider
)
BeforeAll(func() {
manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension)
var ok bool
provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity")
Expect(ok).To(BeTrue())
})
Describe("PluginNames", func() {
It("reports the sonic similarity capability", func() {
names := manager.PluginNames(string(CapabilitySonicSimilarity))
Expect(names).To(ContainElement("test-sonic-similarity"))
})
})
Describe("GetSonicSimilarTracks", func() {
It("returns similar tracks from the plugin", func() {
mf := &model.MediaFile{
ID: "track-1",
Title: "Yesterday",
Artist: "The Beatles",
}
results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1"))
Expect(results[0].Song.Artist).To(Equal("The Beatles"))
Expect(results[0].Similarity).To(Equal(1.0))
Expect(results[1].Similarity).To(Equal(0.9))
Expect(results[2].Similarity).To(Equal(0.8))
})
})
Describe("FindSonicPath", func() {
It("returns a path between two tracks from the plugin", func() {
startMf := &model.MediaFile{
ID: "track-1",
Title: "Yesterday",
Artist: "The Beatles",
}
endMf := &model.MediaFile{
ID: "track-2",
Title: "Tomorrow Never Knows",
Artist: "The Beatles",
}
results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3)
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(3))
Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1"))
Expect(results[0].Song.Artist).To(Equal("The Beatles"))
Expect(results[0].Similarity).To(Equal(1.0))
Expect(results[1].Similarity).To(Equal(0.95))
Expect(results[2].Similarity).To(Equal(0.9))
})
})
})
var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() {
var (
errorManager *Manager
errorProvider sonic.Provider
)
BeforeAll(func() {
errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{
"test-sonic-similarity": {
"error": "simulated plugin error",
},
}, "test-sonic-similarity"+PackageExtension)
var ok bool
errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity")
Expect(ok).To(BeTrue())
})
It("returns error from GetSonicSimilarTracks", func() {
mf := &model.MediaFile{ID: "track-1", Title: "Test"}
_, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from FindSonicPath", func() {
startMf := &model.MediaFile{ID: "track-1", Title: "Start"}
endMf := &model.MediaFile{ID: "track-2", Title: "End"}
_, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
})

View File

@ -0,0 +1,16 @@
module test-sonic-similarity
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,71 @@
// Test plugin for Navidrome sonic similarity integration tests.
// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"errors"
"strconv"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity"
)
func init() {
sonicsimilarity.Register(&testSonicSimilarity{})
}
type testSonicSimilarity struct{}
func checkConfigError() error {
errMsg, hasErr := pdk.GetConfig("error")
if !hasErr || errMsg == "" {
return nil
}
return errors.New(errMsg)
}
func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
if err := checkConfigError(); err != nil {
return sonicsimilarity.SonicSimilarityResponse{}, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
matches := make([]sonicsimilarity.SonicMatch, 0, count)
for i := range count {
matches = append(matches, sonicsimilarity.SonicMatch{
Song: sonicsimilarity.SongRef{
ID: "similar-track-" + strconv.Itoa(i+1),
Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1),
Artist: input.Song.Artist,
},
Similarity: 1.0 - float64(i)*0.1,
})
}
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
}
func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) {
if err := checkConfigError(); err != nil {
return sonicsimilarity.SonicSimilarityResponse{}, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
matches := make([]sonicsimilarity.SonicMatch, 0, count)
for i := range count {
matches = append(matches, sonicsimilarity.SonicMatch{
Song: sonicsimilarity.SongRef{
ID: "path-track-" + strconv.Itoa(i+1),
Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1),
Artist: input.StartSong.Artist,
},
Similarity: 1.0 - float64(i)*0.05,
})
}
return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil
}
func main() {}

View File

@ -0,0 +1,7 @@
{
"name": "Test Sonic Similarity",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test plugin for sonic similarity integration testing",
"capabilities": ["SonicSimilarity"]
}

View File

@ -10,32 +10,48 @@
"playCount": "Riproduzioni",
"title": "Titolo",
"artist": "Artista",
"composer": "Compositore",
"album": "Album",
"path": "Percorso",
"libraryName": "Libreria",
"genre": "Genere",
"compilation": "Compilation",
"year": "Anno",
"size": "Dimensioni",
"updatedAt": "Ultimo aggiornamento",
"bitRate": "Bitrate",
"discSubtitle": "Sottotitoli disco",
"bitDepth": "Profondità di bit",
"sampleRate": "Frequenza di campionamento",
"albumGain": "Guadagno album",
"trackGain": "Guadagno traccia",
"channels": "Canali",
"disc": "Disco %{discNumber}",
"discSubtitle": "Sottotitolo disco",
"starred": "Preferita",
"comment": "Commento",
"rating": "Valutazione",
"quality": "Qualità",
"bpm": "BPM",
"playDate": "Ultima riproduzione",
"channels": "Canali",
"createdAt": ""
"createdAt": "Data di aggiunta",
"grouping": "Raggruppamento",
"mood": "Umore",
"participants": "Partecipanti aggiuntivi",
"tags": "Tag aggiuntivi",
"mappedTags": "Tag mappati",
"rawTags": "Tag grezzi",
"missing": "Mancante"
},
"actions": {
"addToQueue": "Aggiungi alla coda",
"playNow": "Riproduci adesso",
"addToPlaylist": "Aggiungi alla playlist",
"showInPlaylist": "Mostra nella playlist",
"shuffleAll": "Riproduci casualmente",
"download": "Scarica",
"playNext": "Riproduci come successivo",
"info": "Informazioni"
"info": "Informazioni",
"instantMix": "Mix istantaneo"
}
},
"album": {
@ -46,29 +62,38 @@
"duration": "Durata",
"songCount": "Tracce",
"playCount": "Riproduzioni",
"size": "Dimensione",
"name": "Nome",
"libraryName": "Libreria",
"genre": "Genere",
"compilation": "Compilation",
"year": "Anno",
"date": "Data di registrazione",
"originalDate": "Originale",
"releaseDate": "Data di pubblicazione",
"releases": "Pubblicazione |||| Pubblicazioni",
"released": "Pubblicato",
"updatedAt": "Ultimo aggiornamento",
"comment": "Commento",
"rating": "Valutazione",
"createdAt": "Data di creazione",
"size": "Dimensione",
"originalDate": "",
"releaseDate": "Data di pubblicazione",
"releases": "Pubblicazione |||| Pubblicazioni",
"released": "Pubblicato"
"createdAt": "Data di aggiunta",
"recordLabel": "Etichetta",
"catalogNum": "Numero di catalogo",
"releaseType": "Tipo",
"grouping": "Raggruppamento",
"media": "Media",
"mood": "Umore",
"missing": "Mancante"
},
"actions": {
"playAll": "Riproduci",
"playNext": "Riproduci come successivo",
"addToQueue": "Aggiungi alla coda",
"share": "Condividi",
"shuffle": "Riproduci casualmente",
"addToPlaylist": "Aggiungi alla Playlist",
"addToPlaylist": "Aggiungi alla playlist",
"download": "Scarica",
"info": "Informazioni",
"share": "Condividi"
"info": "Informazioni"
},
"lists": {
"all": "Tutti",
@ -86,10 +111,33 @@
"name": "Nome",
"albumCount": "Album",
"songCount": "Numero tracce",
"size": "Dimensione",
"playCount": "Riproduzioni",
"rating": "Valutazione",
"genre": "Genere",
"size": "Dimensione"
"role": "Ruolo",
"missing": "Mancante"
},
"roles": {
"albumartist": "Artista Album |||| Artisti Album",
"artist": "Artista |||| Artisti",
"composer": "Compositore |||| Compositori",
"conductor": "Direttore d'orchestra |||| Direttori d'orchestra",
"lyricist": "Paroliere |||| Parolieri",
"arranger": "Arrangiatore |||| Arrangiatori",
"producer": "Produttore |||| Produttori",
"director": "Direttore |||| Direttori",
"engineer": "Ingegnere del suono |||| Ingegneri del suono",
"mixer": "Mixer |||| Mixer",
"remixer": "Remixer |||| Remixer",
"djmixer": "DJ Mixer |||| DJ Mixer",
"performer": "Esecutore |||| Esecutori",
"maincredit": "Artista Album o Artista |||| Artisti Album o Artisti"
},
"actions": {
"topSongs": "Brani più ascoltati",
"shuffle": "Riproduci casualmente",
"radio": "Radio"
}
},
"user": {
@ -97,31 +145,39 @@
"fields": {
"userName": "Nome utente",
"isAdmin": "Amministratore",
"lastLoginAt": "Ultimo accesso",
"lastLoginAt": "Ultimo login",
"lastAccessAt": "Ultimo accesso",
"updatedAt": "Ultimo aggiornamento",
"name": "Nome",
"password": "Password",
"createdAt": "Creato a",
"createdAt": "Creato il",
"changePassword": "Cambiare la password?",
"currentPassword": "Password Attuale",
"newPassword": "Nuova Password",
"token": "Token"
"token": "Token",
"libraries": "Librerie"
},
"helperTexts": {
"name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso"
"name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso",
"libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite"
},
"notifications": {
"created": "Utente creato",
"updated": "Utente aggiornato",
"deleted": "Utente eliminato"
},
"validation": {
"librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori"
},
"message": {
"listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.",
"clickHereForToken": "Clicca qui per ottenere il tuo token"
"listenBrainzToken": "Inserisci il tuo token utente ListenBrainz",
"clickHereForToken": "Clicca qui per ottenere il tuo token",
"selectAllLibraries": "Seleziona tutte le librerie",
"adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie"
}
},
"player": {
"name": "Client |||| Client",
"name": "Lettore |||| Lettori",
"fields": {
"name": "Nome",
"transcodingId": "Transcodifica",
@ -130,7 +186,7 @@
"userName": "Nome utente",
"lastSeen": "Ultimo accesso",
"reportRealPath": "Mostra percorso reale",
"scrobbleEnabled": ""
"scrobbleEnabled": "Invia scrobble ai servizi esterni"
}
},
"transcoding": {
@ -157,45 +213,203 @@
"path": "Importa da"
},
"actions": {
"selectPlaylist": "Aggiungi tracce alla playlist:",
"addNewPlaylist": "Aggiungi \"%{name}\"",
"selectPlaylist": "Seleziona una playlist:",
"addNewPlaylist": "Crea \"%{name}\"",
"export": "Esporta",
"saveQueue": "Salva la coda nella playlist",
"makePublic": "Rendi Pubblica",
"makePrivate": "Rendi Privata"
"makePrivate": "Rendi Privata",
"searchOrCreate": "Cerca playlist o digita per crearne una nuova...",
"pressEnterToCreate": "Premi Invio per creare una nuova playlist",
"removeFromSelection": "Rimuovi dalla selezione"
},
"message": {
"duplicate_song": "Aggiungere i duplicati",
"song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?"
"song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?",
"noPlaylistsFound": "Nessuna playlist trovata",
"noPlaylists": "Nessuna playlist disponibile"
}
},
"radio": {
"name": "Radio |||| Radio",
"fields": {
"name": "Nome",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
"streamUrl": "URL dello stream",
"homePageUrl": "URL della pagina web",
"updatedAt": "Ultimo aggiornamento",
"createdAt": "Data di creazione"
},
"actions": {
"playNow": ""
"playNow": "Riproduci adesso"
}
},
"share": {
"name": "",
"name": "Condivisione |||| Condivisioni",
"fields": {
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
"username": "Condiviso da",
"url": "URL",
"description": "Descrizione",
"downloadable": "Consenti i download?",
"contents": "Contenuti",
"expiresAt": "Scade il",
"lastVisitedAt": "Ultima visita",
"visitCount": "Visite",
"format": "Formato",
"maxBitRate": "Bitrate massimo",
"updatedAt": "Ultimo aggiornamento",
"createdAt": "Data di creazione"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "File mancante |||| File mancanti",
"empty": "Nessun file mancante",
"fields": {
"path": "Percorso",
"size": "Dimensione",
"libraryName": "Libreria",
"updatedAt": "Scomparso il"
},
"actions": {
"remove": "Rimuovi",
"remove_all": "Rimuovi tutti"
},
"notifications": {
"removed": "File mancanti rimossi"
}
},
"library": {
"name": "Libreria |||| Librerie",
"fields": {
"name": "Nome",
"path": "Percorso",
"remotePath": "Percorso remoto",
"lastScanAt": "Ultima scansione",
"songCount": "Tracce",
"albumCount": "Album",
"artistCount": "Artisti",
"totalSongs": "Tracce",
"totalAlbums": "Album",
"totalArtists": "Artisti",
"totalFolders": "Cartelle",
"totalFiles": "File",
"totalMissingFiles": "File mancanti",
"totalSize": "Dimensione totale",
"totalDuration": "Durata",
"defaultNewUsers": "Predefinita per i nuovi utenti",
"createdAt": "Creata il",
"updatedAt": "Aggiornata il"
},
"sections": {
"basic": "Informazioni di base",
"statistics": "Statistiche"
},
"actions": {
"scan": "Scansiona la libreria",
"quickScan": "Scansione rapida",
"fullScan": "Scansione completa",
"manageUsers": "Gestisci accesso utenti",
"viewDetails": "Visualizza dettagli"
},
"notifications": {
"created": "Libreria creata con successo",
"updated": "Libreria aggiornata con successo",
"deleted": "Libreria eliminata con successo",
"scanStarted": "Scansione della libreria avviata",
"quickScanStarted": "Scansione rapida avviata",
"fullScanStarted": "Scansione completa avviata",
"scanError": "Errore durante l'avvio della scansione. Controlla i log",
"scanCompleted": "Scansione della libreria completata"
},
"validation": {
"nameRequired": "Il nome della libreria è obbligatorio",
"pathRequired": "Il percorso della libreria è obbligatorio",
"pathNotDirectory": "Il percorso della libreria deve essere una directory",
"pathNotFound": "Percorso della libreria non trovato",
"pathNotAccessible": "Il percorso della libreria non è accessibile",
"pathInvalid": "Percorso della libreria non valido"
},
"messages": {
"deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.",
"scanInProgress": "Scansione in corso...",
"noLibrariesAssigned": "Nessuna libreria assegnata a questo utente"
}
},
"plugin": {
"name": "Plugin |||| Plugin",
"fields": {
"id": "ID",
"name": "Nome",
"description": "Descrizione",
"version": "Versione",
"author": "Autore",
"website": "Sito web",
"permissions": "Permessi",
"enabled": "Abilitato",
"status": "Stato",
"path": "Percorso",
"lastError": "Errore",
"hasError": "Errore",
"updatedAt": "Aggiornato il",
"createdAt": "Installato il",
"configKey": "Chiave",
"configValue": "Valore",
"allUsers": "Consenti tutti gli utenti",
"selectedUsers": "Utenti selezionati",
"allLibraries": "Consenti tutte le librerie",
"selectedLibraries": "Librerie selezionate",
"allowWriteAccess": "Consenti accesso in scrittura"
},
"sections": {
"status": "Stato",
"info": "Informazioni sul plugin",
"configuration": "Configurazione",
"manifest": "Manifest",
"usersPermission": "Permessi utenti",
"libraryPermission": "Permesso libreria"
},
"status": {
"enabled": "Abilitato",
"disabled": "Disabilitato"
},
"actions": {
"enable": "Abilita",
"disable": "Disabilita",
"disabledDueToError": "Correggi l'errore prima di abilitare",
"disabledUsersRequired": "Seleziona gli utenti prima di abilitare",
"disabledLibrariesRequired": "Seleziona le librerie prima di abilitare",
"addConfig": "Aggiungi configurazione",
"rescan": "Riscansiona"
},
"notifications": {
"enabled": "Plugin abilitato",
"disabled": "Plugin disabilitato",
"updated": "Plugin aggiornato",
"error": "Errore durante l'aggiornamento del plugin"
},
"validation": {
"invalidJson": "La configurazione deve essere un JSON valido"
},
"messages": {
"configHelp": "Configura il plugin usando coppie chiave-valore. Lascia vuoto se il plugin non richiede configurazione.",
"configValidationError": "Validazione della configurazione fallita:",
"schemaRenderError": "Impossibile visualizzare il modulo di configurazione. Lo schema del plugin potrebbe non essere valido.",
"clickPermissions": "Clicca su un permesso per i dettagli",
"noConfig": "Nessuna configurazione impostata",
"allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.",
"noUsers": "Nessun utente selezionato",
"permissionReason": "Motivo",
"usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.",
"allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.",
"noLibraries": "Nessuna libreria selezionata",
"librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.",
"allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.",
"requiredHosts": "Host richiesti"
},
"placeholders": {
"configKey": "chiave",
"configValue": "valore"
}
}
},
@ -206,12 +420,13 @@
"confirmPassword": "Conferma la password",
"buttonCreateAdmin": "Crea amministratore",
"auth_check_error": "Per favore accedi per continuare",
"user_menu": "Profile",
"user_menu": "Profilo",
"username": "Nome utente",
"password": "Password",
"sign_in": "Accedi",
"sign_in_error": "Autenticazione fallita, per favore riprova",
"logout": "Disconnetti"
"logout": "Disconnetti",
"insightsCollectionNote": "Navidrome raccoglie dati di utilizzo anonimi per\nmigliorare il progetto. Clicca [qui] per saperne di più\ne per disattivarlo se lo desideri"
},
"validation": {
"invalidChars": "Per favore usa solo lettere e numeri",
@ -226,13 +441,14 @@
"oneOf": "Deve essere uno di: %{options}",
"regex": "Deve rispettare il formato (espressione regolare): %{pattern}",
"unique": "Deve essere unico",
"url": ""
"url": "Deve essere un URL valido"
},
"action": {
"add_filter": "Aggiungi un filtro",
"add": "Aggiungi",
"back": "Indietro",
"bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Annulla",
"clear_input_value": "Cancella",
"clone": "Duplica",
@ -244,7 +460,7 @@
"list": "Elenco",
"refresh": "Aggiorna",
"remove_filter": "Rimuovi questo filtro",
"remove": "Remove",
"remove": "Rimuovi",
"save": "Salva",
"search": "Cerca",
"show": "Mostra",
@ -255,17 +471,16 @@
"open_menu": "Apri menù",
"close_menu": "Chiudi menù",
"unselect": "Deseleziona",
"skip": "Saltare i duplicati",
"bulk_actions_mobile": "",
"share": "",
"download": ""
"skip": "Salta",
"share": "Condividi",
"download": "Scarica"
},
"boolean": {
"true": "Si",
"true": "Sì",
"false": "No"
},
"page": {
"create": "Aggiungi %{name}",
"create": "Crea %{name}",
"dashboard": "Pannello di controllo",
"edit": "%{name} #%{id}",
"error": "Qualcosa è andato storto",
@ -274,7 +489,7 @@
"not_found": "Non trovato",
"show": "%{name} #%{id}",
"empty": "Nessun %{name} per adesso.",
"invite": "Vuoi invitare un amico?"
"invite": "Vuoi aggiungerne uno?"
},
"input": {
"file": {
@ -308,17 +523,17 @@
"loading": "La pagina si sta caricando, solo un momento per favore",
"no": "No",
"not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.",
"yes": "Si",
"unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?"
"yes": "Sì",
"unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?"
},
"navigation": {
"no_results": "Nessun risultato trovato",
"no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.",
"page_out_of_boundaries": "Il numero di pagina %{page} è fuori dallintervallo",
"page_out_from_end": "Non è possibile andare oltre lultima pagina",
"page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall'intervallo",
"page_out_from_end": "Non è possibile andare oltre l'ultima pagina",
"page_out_from_begin": "Non è possibile andare prima della prima pagina",
"page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}",
"page_rows_per_page": "Righe per pagina:",
"page_rows_per_page": "Elementi per pagina:",
"next": "Successivo",
"prev": "Precedente",
"skip_nav": "Passa al contenuto"
@ -334,7 +549,7 @@
"i18n_error": "Impossibile caricare la traduzione per la lingua selezionata",
"canceled": "Azione annullata",
"logged_out": "La sessione è scaduta, per favore accedi di nuovo.",
"new_version": "Una nuova versione è disponibile! Ricarica la pagina"
"new_version": "Una nuova versione è disponibile! Ricarica la pagina."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Colonne da mostrare",
@ -344,39 +559,58 @@
}
},
"message": {
"note": "Note",
"transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso linterfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con lopzione %{config}.",
"transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso linterfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.",
"uploadCover": "Carica copertina",
"removeCover": "Rimuovi copertina",
"coverUploaded": "Copertina aggiornata",
"coverRemoved": "Copertina rimossa",
"coverUploadError": "Errore durante il caricamento della copertina",
"coverRemoveError": "Errore durante la rimozione della copertina",
"note": "NOTA",
"transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l'interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l'opzione %{config}.",
"transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi di sistema dalle impostazioni di transcodifica tramite l'interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.",
"songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist",
"noPlaylistsAvailable": "Nessuna playlist",
"noSimilarSongsFound": "Nessuna traccia simile trovata",
"startingInstantMix": "Caricamento del Mix istantaneo...",
"noTopSongsFound": "Nessun brano più ascoltato trovato",
"noPlaylistsAvailable": "Nessuna disponibile",
"delete_user_title": "Rimuovi utente '%{name}'",
"delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?",
"delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?",
"remove_missing_title": "Rimuovi i file mancanti",
"remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.",
"remove_all_missing_title": "Rimuovi tutti i file mancanti",
"remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.",
"notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser",
"notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS",
"lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato",
"lastfmLinkFailure": "Non è stato possible collegare Last.fm",
"lastfmLinkFailure": "Non è stato possibile collegare Last.fm",
"lastfmUnlinkSuccess": "Lo scrobbling è stato disabilitato e Last.fm è stato disconnesso",
"lastfmUnlinkFailure": "Non è stato possibile scollegare Last.fm",
"listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente: %{user}",
"listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz disconnesso e scrobbling disabilitato",
"listenBrainzUnlinkFailure": "Non è stato possibile disconnettere ListenBrainz",
"openIn": {
"lastfm": "Apri in Last.fm",
"musicbrainz": "Apri in MusicBrainz"
},
"lastfmLink": "Per saperne di più...",
"listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente %{user}",
"listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "",
"listenBrainzUnlinkFailure": "",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
"shareOriginalFormat": "Condividi nel formato originale",
"shareDialogTitle": "Condividi %{resource} '%{name}'",
"shareBatchDialogTitle": "Condividi 1 %{resource} |||| Condividi %{smart_count} %{resource}",
"shareCopyToClipboard": "Copia negli appunti: Ctrl+C, Invio",
"shareSuccess": "URL copiato negli appunti: %{url}",
"shareFailure": "Errore durante la copia dell'URL %{url} negli appunti",
"downloadDialogTitle": "Scarica %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Scarica nel formato originale"
},
"menu": {
"library": "Libreria",
"librarySelector": {
"allLibraries": "Tutte le librerie (%{count})",
"multipleLibraries": "%{selected} di %{total} librerie",
"selectLibraries": "Seleziona librerie",
"none": "Nessuna"
},
"settings": "Impostazioni",
"version": "Versione",
"theme": "Tema",
@ -387,21 +621,22 @@
"language": "Lingua",
"defaultView": "Vista Predefinita",
"desktop_notifications": "Notifiche desktop",
"lastfmNotConfigured": "La chiave API di Last.fm non è configurata",
"lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm",
"listenBrainzScrobbling": "",
"replaygain": "",
"preAmp": "",
"listenBrainzScrobbling": "Esegui lo scrobbling tramite ListenBrainz",
"replaygain": "Modalità ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "",
"album": "",
"track": ""
"none": "Disabilitato",
"album": "Usa guadagno album",
"track": "Usa guadagno traccia"
}
}
},
"albumList": "Album",
"about": "Info",
"playlists": "Playlist",
"sharedPlaylists": "Playlist Condivise"
"sharedPlaylists": "Playlist Condivise",
"about": "Info"
},
"player": {
"playListsText": "Coda",
@ -432,29 +667,59 @@
"links": {
"homepage": "Sito web",
"source": "Codice sorgente",
"featureRequests": "Richieste"
"featureRequests": "Richieste",
"lastInsightsCollection": "Ultima raccolta dati",
"insights": {
"disabled": "Disabilitato",
"waiting": "In attesa"
}
},
"tabs": {
"about": "Info",
"config": "Configurazione"
},
"config": {
"configName": "Nome configurazione",
"environmentVariable": "Variabile d'ambiente",
"currentValue": "Valore attuale",
"configurationFile": "File di configurazione",
"exportToml": "Esporta configurazione (TOML)",
"downloadToml": "Scarica configurazione (TOML)",
"exportSuccess": "Configurazione esportata negli appunti in formato TOML",
"exportFailed": "Impossibile copiare la configurazione",
"devFlagsHeader": "Flag di sviluppo (soggetti a modifiche/rimozione)",
"devFlagsComment": "Queste sono impostazioni sperimentali e potrebbero essere rimosse in versioni future"
}
},
"activity": {
"title": "Attività",
"totalScanned": "Cartelle scansionate",
"quickScan": "Scansione veloce",
"fullScan": "Scansione completa",
"serverUptime": "Periodo di attività",
"serverDown": "OFFLINE"
"totalScanned": "Cartelle scansionate totali",
"quickScan": "Rapida",
"fullScan": "Completa",
"selectiveScan": "Selettiva",
"serverUptime": "Periodo di attività del server",
"serverDown": "OFFLINE",
"scanType": "Ultima scansione",
"status": "Errore di scansione",
"elapsedTime": "Tempo trascorso"
},
"nowPlaying": {
"title": "In riproduzione",
"empty": "Nessuna riproduzione in corso",
"minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa"
},
"help": {
"title": "Scorciatoie da Tastiera",
"title": "Scorciatoie da Tastiera di Navidrome",
"hotkeys": {
"show_help": "Mostra questa schermata",
"toggle_menu": "Mostra/Nascondi la barra laterale",
"toggle_play": "Riproduzione/Pausa",
"prev_song": "Traccia Precedente",
"next_song": "Traccia Successiva",
"current_song": "Vai alla traccia corrente",
"vol_up": "Alza il Volume",
"vol_down": "Abbassa il Volume",
"toggle_love": "Aggiungi questa traccia ai preferiti",
"current_song": ""
"toggle_love": "Aggiungi questa traccia ai preferiti"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More