mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into subsonic-folder
This commit is contained in:
commit
0c988edfc0
@ -164,6 +164,7 @@ RUN touch /.nddockerenv
|
||||
|
||||
EXPOSE ${ND_PORT}
|
||||
WORKDIR /app
|
||||
ENV PATH="/app:${PATH}"
|
||||
|
||||
ENTRYPOINT ["/app/navidrome"]
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@ -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/*")
|
||||
|
||||
|
||||
224
cmd/pls.go
224
cmd/pls.go
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
354
core/artwork/e2e/album_test.go
Normal file
354
core/artwork/e2e/album_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
167
core/artwork/e2e/artist_test.go
Normal file
167
core/artwork/e2e/artist_test.go
Normal 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]
|
||||
}
|
||||
276
core/artwork/e2e/disc_test.go
Normal file
276
core/artwork/e2e/disc_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
184
core/artwork/e2e/helpers_test.go
Normal file
184
core/artwork/e2e/helpers_test.go
Normal 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)
|
||||
110
core/artwork/e2e/mediafile_test.go
Normal file
110
core/artwork/e2e/mediafile_test.go
Normal 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]
|
||||
}
|
||||
158
core/artwork/e2e/playlist_test.go
Normal file
158
core/artwork/e2e/playlist_test.go
Normal 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
|
||||
}
|
||||
42
core/artwork/e2e/radio_test.go
Normal file
42
core/artwork/e2e/radio_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
106
core/artwork/e2e/suite_test.go
Normal file
106
core/artwork/e2e/suite_test.go
Normal 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]
|
||||
}
|
||||
BIN
core/artwork/e2e/testdata/embedded_art.mp3
vendored
Normal file
BIN
core/artwork/e2e/testdata/embedded_art.mp3
vendored
Normal file
Binary file not shown.
44
core/artwork/library_fs.go
Normal file
44
core/artwork/library_fs.go
Normal 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
|
||||
}
|
||||
45
core/artwork/library_fs_test.go
Normal file
45
core/artwork/library_fs_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
92
core/artwork/sources_internal_test.go
Normal file
92
core/artwork/sources_internal_test.go
Normal 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") }
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
130
core/sonic/sonic.go
Normal 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)
|
||||
}
|
||||
17
core/sonic/sonic_suite_test.go
Normal file
17
core/sonic/sonic_suite_test.go
Normal 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
146
core/sonic/sonic_test.go
Normal 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
11
go.mod
@ -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
39
go.sum
@ -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=
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
62
model/criteria/sort.go
Normal 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
103
model/criteria/sort_test.go
Normal 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
37
model/criteria/walk.go
Normal 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()
|
||||
}
|
||||
64
model/criteria/walk_test.go
Normal file
64
model/criteria/walk_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
@ -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
501
persistence/criteria_sql.go
Normal 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
|
||||
}
|
||||
234
persistence/criteria_sql_test.go
Normal file
234
persistence/criteria_sql_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
345
persistence/e2e/e2e_suite_test.go
Normal file
345
persistence/e2e/e2e_suite_test.go
Normal 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(®ularUserWithPass)).To(Succeed())
|
||||
|
||||
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
|
||||
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
|
||||
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
adminUser.Libraries = loadedUser.Libraries
|
||||
|
||||
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())}
|
||||
}
|
||||
374
persistence/e2e/smartplaylist_test.go
Normal file
374
persistence/e2e/smartplaylist_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
203
persistence/smart_playlist_repository.go
Normal file
203
persistence/smart_playlist_repository.go
Normal 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
|
||||
}
|
||||
531
persistence/smart_playlist_repository_test.go
Normal file
531
persistence/smart_playlist_repository_test.go
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.0–1.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
|
||||
@ -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: |-
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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: |-
|
||||
|
||||
32
plugins/capabilities/sonic_similarity.go
Normal file
32
plugins/capabilities/sonic_similarity.go
Normal 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"`
|
||||
}
|
||||
92
plugins/capabilities/sonic_similarity.yaml
Normal file
92
plugins/capabilities/sonic_similarity.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
|
||||
136
plugins/pdk/go/sonicsimilarity/sonicsimilarity.go
Normal file
136
plugins/pdk/go/sonicsimilarity/sonicsimilarity.go
Normal 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
|
||||
}
|
||||
71
plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go
Normal file
71
plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go
Normal 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) {}
|
||||
@ -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;
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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")]
|
||||
|
||||
141
plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs
Normal file
141
plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs
Normal 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))
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
92
plugins/sonic_similarity_adapter.go
Normal file
92
plugins/sonic_similarity_adapter.go
Normal 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)
|
||||
110
plugins/sonic_similarity_adapter_test.go
Normal file
110
plugins/sonic_similarity_adapter_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
16
plugins/testdata/test-sonic-similarity/go.mod
vendored
Normal file
16
plugins/testdata/test-sonic-similarity/go.mod
vendored
Normal 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
|
||||
14
plugins/testdata/test-sonic-similarity/go.sum
vendored
Normal file
14
plugins/testdata/test-sonic-similarity/go.sum
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
71
plugins/testdata/test-sonic-similarity/main.go
vendored
Normal file
71
plugins/testdata/test-sonic-similarity/main.go
vendored
Normal 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() {}
|
||||
7
plugins/testdata/test-sonic-similarity/manifest.json
vendored
Normal file
7
plugins/testdata/test-sonic-similarity/manifest.json
vendored
Normal 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"]
|
||||
}
|
||||
@ -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 dall’intervallo",
|
||||
"page_out_from_end": "Non è possibile andare oltre l’ultima 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 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 remoti attraverso l’interfaccia 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
Loading…
x
Reference in New Issue
Block a user