diff --git a/Dockerfile b/Dockerfile index 105656afb..ad1e2a41c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,6 +164,7 @@ RUN touch /.nddockerenv EXPOSE ${ND_PORT} WORKDIR /app +ENV PATH="/app:${PATH}" ENTRYPOINT ["/app/navidrome"] diff --git a/Makefile b/Makefile index ad96afd31..e303017c7 100644 --- a/Makefile +++ b/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/*") diff --git a/cmd/pls.go b/cmd/pls.go index 9b94c9e8f..95cbe4eec 100644 --- a/cmd/pls.go +++ b/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 +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f66df2e75..0939eef4d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index d87a8d6d3..bb5c5b5f3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -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)), diff --git a/conf/configuration.go b/conf/configuration.go index 9ee77c8bf..251bb0248 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) diff --git a/consts/consts.go b/consts/consts.go index 3db0b831a..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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 diff --git a/core/archiver.go b/core/archiver.go index 8305c4f6c..96cc2c31e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -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 { diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 12a7085e8..c95371959 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -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" diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index dfd66e5e5..d42d7f3e4 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -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 +} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go new file mode 100644 index 000000000..3d5523afd --- /dev/null +++ b/core/artwork/e2e/album_test.go @@ -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"))) + }) + }) +}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go new file mode 100644 index 000000000..d959b1d60 --- /dev/null +++ b/core/artwork/e2e/artist_test.go @@ -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() { + // / + // └── artwork/ + // └── artist/ + // └── _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/ (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() { + // / + // └── 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] +} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go new file mode 100644 index 000000000..7569cbc32 --- /dev/null +++ b/core/artwork/e2e/disc_test.go @@ -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"))) + }) + }) +}) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go new file mode 100644 index 000000000..e3abca097 --- /dev/null +++ b/core/artwork/e2e/helpers_test.go @@ -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 /artwork// 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) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go new file mode 100644 index 000000000..1f43a3827 --- /dev/null +++ b/core/artwork/e2e/mediafile_test.go @@ -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] +} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go new file mode 100644 index 000000000..d28efca8e --- /dev/null +++ b/core/artwork/e2e/playlist_test.go @@ -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 (/artwork/playlist/) +// 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() { + // / + // └── 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() { + // / + // ├── 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() { + // / + // ├── 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() { + // / + // └── 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 +} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go new file mode 100644 index 000000000..73ee5f377 --- /dev/null +++ b/core/artwork/e2e/radio_test.go @@ -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() { + // / + // └── 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()) + }) + }) +}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go new file mode 100644 index 000000000..9ce0edb8b --- /dev/null +++ b/core/artwork/e2e/suite_test.go @@ -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] +} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 new file mode 100644 index 000000000..18cb90674 Binary files /dev/null and b/core/artwork/e2e/testdata/embedded_art.mp3 differ diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go new file mode 100644 index 000000000..ff557294e --- /dev/null +++ b/core/artwork/library_fs.go @@ -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 +} diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go new file mode 100644 index 000000000..acf08fda3 --- /dev/null +++ b/core/artwork/library_fs_test.go @@ -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()) + }) +}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 35d489b6c..8d7e14fd0 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -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( diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index a8a0eae3e..03412b6d9 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -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)) }) }) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 96ba08b8f..37b7b6dee 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -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) { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 220c7554f..e2a1f2094 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -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() { diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 30d4968e1..de0a765f0 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -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 diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index 7b633342f..8264ee27b 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -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}, } }) diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index cf25c8f5d..eac3c5e70 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -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, diff --git a/core/artwork/sources.go b/core/artwork/sources.go index d830593fc..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -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 == "" { diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go new file mode 100644 index 000000000..4282575a5 --- /dev/null +++ b/core/artwork/sources_internal_test.go @@ -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") } diff --git a/core/auth/claims.go b/core/auth/claims.go index c0e4dea7f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -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 } diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index cf6b07263..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -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)) }) diff --git a/core/external/provider.go b/core/external/provider.go index 7e8aaba1c..4f3295cc7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -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 } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index abeda5c9e..80790c8d6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -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 ) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 01b284172..1649015d9 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -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)) + }) + }) }) diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index cf6c99e28..54c2a368e 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -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. diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 8996cf71d..42e3ec88d 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -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)) diff --git a/core/playlists/import.go b/core/playlists/import.go index 4462554c7..9d3ecabc5 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -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 } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 53855d781..f2866fb60 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -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 +} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index b9f5c92a2..a64c337c9 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -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). diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index a0086cd2d..3da24706c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -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 } diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3fecda0d5..c9b7c4ea6 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -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) } diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 097bc6310..90d22327a 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -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) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d1338ca39..161d04d7d 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -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 } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..734f8f66f 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -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 } diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go new file mode 100644 index 000000000..19eb69c65 --- /dev/null +++ b/core/sonic/sonic.go @@ -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) +} diff --git a/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go new file mode 100644 index 000000000..7058e5de9 --- /dev/null +++ b/core/sonic/sonic_suite_test.go @@ -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") +} diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go new file mode 100644 index 000000000..81739b726 --- /dev/null +++ b/core/sonic/sonic_test.go @@ -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()) + }) + }) +}) diff --git a/go.mod b/go.mod index b7dbb9eeb..a4c0c014b 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 29b979413..3e665ba02 100644 --- a/go.sum +++ b/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= diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 278acf34c..31d208d08 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -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) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index a76b3fc1f..092cfd36a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -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})) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index bc3c7a3d3..b35913f28 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -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} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index accdebd3d..270a14473 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -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()) }) }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index f6ab56eda..18a664988 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -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 } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 336f914de..7def18934 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -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 } } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 5f756f97d..bfdca3e31 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -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}}`), ) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go new file mode 100644 index 000000000..e38fe0551 --- /dev/null +++ b/model/criteria/sort.go @@ -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 +} diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go new file mode 100644 index 000000000..35db88549 --- /dev/null +++ b/model/criteria/sort_test.go @@ -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"})) + }) +}) diff --git a/model/criteria/walk.go b/model/criteria/walk.go new file mode 100644 index 000000000..7445c2aef --- /dev/null +++ b/model/criteria/walk.go @@ -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() +} diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go new file mode 100644 index 000000000..2e0f12f8d --- /dev/null +++ b/model/criteria/walk_test.go @@ -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")) + }) +}) diff --git a/model/playlist.go b/model/playlist.go index e2f93993d..dc549f039 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -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) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go new file mode 100644 index 000000000..9a99cfa8e --- /dev/null +++ b/persistence/criteria_sql.go @@ -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 +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go new file mode 100644 index 000000000..40d21c9bf --- /dev/null +++ b/persistence/criteria_sql_test.go @@ -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()) + }) + }) +}) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go new file mode 100644 index 000000000..f42292f02 --- /dev/null +++ b/persistence/e2e/e2e_suite_test.go @@ -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())} +} diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go new file mode 100644 index 000000000..a844dc982 --- /dev/null +++ b/persistence/e2e/smartplaylist_test.go @@ -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")) + }) + }) +}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 8d1bbe0f8..4152505d2 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -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 { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 88cb5f697..cfabd0983 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -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") - }) - }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go new file mode 100644 index 000000000..54f316152 --- /dev/null +++ b/persistence/smart_playlist_repository.go @@ -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 +} diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go new file mode 100644 index 000000000..207fe0c36 --- /dev/null +++ b/persistence/smart_playlist_repository_test.go @@ -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") + }) + }) +}) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 1d4116b5d..e9b961d91 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -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) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index d0e26c8e3..b54e5856a 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -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", diff --git a/plugins/README.md b/plugins/README.md index e37a94d7a..048cf549d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 \ No newline at end of file diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 4ac907559..04dd283dd 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -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: |- diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 34cf60015..ed8a4fb6c 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -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"` diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index f62da1745..8ada5f7e4 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -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: |- diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go new file mode 100644 index 000000000..aadb9396e --- /dev/null +++ b/plugins/capabilities/sonic_similarity.go @@ -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"` +} diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml new file mode 100644 index 000000000..cba97d9b0 --- /dev/null +++ b/plugins/capabilities/sonic_similarity.yaml @@ -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 diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 43ebc0e4b..c66b027d7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -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 diff --git a/plugins/manager.go b/plugins/manager.go index 0c7c91ed8..0e9419bfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -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. diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 451ef26d1..52542300c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -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 diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 188371fee..6696bc4a1 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -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"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 91eec4997..fb3e3fb1a 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -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"` diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index e16bfed4b..d27ae3a9c 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -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"` diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 86a71af03..9e6f706ac 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -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"` diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go new file mode 100644 index 000000000..7a2681f93 --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -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 +} diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go new file mode 100644 index 000000000..28d86301c --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -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) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 85375b525..b0361a3a3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -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; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index fcfe553f8..0a861a1ed 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -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")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index dd42e6803..348460374 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -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")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs new file mode 100644 index 000000000..eb2868929 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -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, +} + +/// 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) -> 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; + /// FindSonicPath + fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result; +} + +/// 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> { + 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> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 4f7cd4661..302f5e1da 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -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 diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 0ee229022..c56d8a900 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -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()) }) }) }) diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go new file mode 100644 index 000000000..ff399680e --- /dev/null +++ b/plugins/sonic_similarity_adapter.go @@ -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) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go new file mode 100644 index 000000000..daea04761 --- /dev/null +++ b/plugins/sonic_similarity_adapter_test.go @@ -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")) + }) +}) diff --git a/plugins/testdata/test-sonic-similarity/go.mod b/plugins/testdata/test-sonic-similarity/go.mod new file mode 100644 index 000000000..6265288f9 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.mod @@ -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 diff --git a/plugins/testdata/test-sonic-similarity/go.sum b/plugins/testdata/test-sonic-similarity/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.sum @@ -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= diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go new file mode 100644 index 000000000..e315bff15 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -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() {} diff --git a/plugins/testdata/test-sonic-similarity/manifest.json b/plugins/testdata/test-sonic-similarity/manifest.json new file mode 100644 index 000000000..a55c4021f --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/manifest.json @@ -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"] +} diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 11fadb46b..b91c04064 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -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" } } -} +} \ No newline at end of file diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index ab5f77ae0..f726343f2 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -100,7 +100,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. continue } // BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc) - pls, err := p.pls.ImportFile(p.ctx, folder, f.Name()) + pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name()) if err != nil { continue } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 06e6fa686..0e01a7549 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -97,9 +97,9 @@ var _ = Describe("phasePlaylists", func() { _ = os.WriteFile(file1, []byte{}, 0600) _ = os.WriteFile(file2, []byte{}, 0600) - pls.On("ImportFile", mock.Anything, folder, "playlist1.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). Return(&model.Playlist{}, nil) - pls.On("ImportFile", mock.Anything, folder, "playlist2.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u"). Return(&model.Playlist{}, nil) _, err := phase.processPlaylistsInFolder(folder) @@ -134,7 +134,7 @@ type mockPlaylists struct { playlists.Playlists } -func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { args := p.Called(ctx, folder, filename) return args.Get(0).(*model.Playlist), args.Error(1) } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4ad9e3daa..7ce8de2e6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } -// noopPlayTracker implements scrobbler.PlayTracker -type noopPlayTracker struct{} - -func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { - return nil -} - -func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { - return nil, nil -} - -func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { - return nil -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -513,12 +497,13 @@ func setupTestDB() { s, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(nil), decider, + nil, ) } diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go index 0f7e92083..ec9b070de 100644 --- a/server/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + + Describe("ReportPlayback", Ordered, func() { + var songID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("returns error when required params are missing", func() { + resp := doReq("reportPlayback") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for invalid state", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "invalid", + ) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("starting report creates a getNowPlaying entry", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.Status).To(Equal(responses.StatusOK)) + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("starting")) + }) + + It("playing report updates getNowPlaying state and position", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "playing", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("playing")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000))) + }) + + It("paused report freezes position in getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "paused", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("paused")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000))) + }) + + It("stopped report removes entry from getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "90000", + "state", "stopped", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(BeEmpty()) + }) + + It("accepts mediaType=podcast without error", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "podcast", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("accepts optional playbackRate and ignoreScrobble", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "5000", + "state", "playing", + "playbackRate", "1.5", + "ignoreScrobble", "true", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5)) + }) + }) }) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go new file mode 100644 index 000000000..40161470b --- /dev/null +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -0,0 +1,273 @@ +package e2e + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service +// backed by the given provider and the shared e2e DataStore. +func buildSonicRouter(provider sonic.Provider) *subsonic.Router { + loader := &mockSonicPluginLoader{provider: provider} + m := matcher.New(ds) + sonicSvc := sonic.New(ds, loader, m) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + return subsonic.New( + ds, + noopArtwork{}, + &spyStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + nil, // scanner + events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), + decider, + sonicSvc, + ) +} + +// doSonicReq makes a request through a sonic-enabled router and returns the parsed response. +func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder. +func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return w +} + +var _ = Describe("Sonic Similarity Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Context("without sonic similarity plugin", func() { + Describe("getSonicSimilarTracks", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("getSonicSimilarTracks", "id", "any-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("findSonicPath", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Context("with sonic similarity plugin", func() { + var ( + sonicRouter *subsonic.Router + comeTogether model.MediaFile + something model.MediaFile + ) + + BeforeEach(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + comeTogether = songs[0] + + songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Something"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + something = songs[0] + + provider := &mockSonicProvider{ + similarIDs: []string{something.ID, comeTogether.ID}, + pathIDs: []string{comeTogether.ID, something.ID}, + } + sonicRouter = buildSonicRouter(provider) + }) + + Describe("getSonicSimilarTracks", func() { + It("returns similar tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Something")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Come Together")) + Expect(matches[1].Similarity).To(Equal(0.9)) + }) + + It("respects the count parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(*resp.SonicMatches).To(HaveLen(1)) + }) + + It("returns an error for a missing id parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent song ID", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns correct JSON structure", func() { + w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + matches := *wrapper.Subsonic.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Similarity).To(BeNumerically(">", 0)) + Expect(matches[0].Entry.Id).ToNot(BeEmpty()) + }) + }) + + Describe("findSonicPath", func() { + It("returns a path between two tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Come Together")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Something")) + Expect(matches[1].Similarity).To(Equal(0.95)) + }) + + It("returns an error for a missing startSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a missing endSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent start song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", "non-existent-id", + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent end song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", "non-existent-id", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + }) +}) + +// mockSonicProvider returns results using IDs from the real test library, +// so that the matcher can resolve them back to actual MediaFiles. +type mockSonicProvider struct { + similarIDs []string + pathIDs []string +} + +func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.similarIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return results, nil +} + +func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.pathIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return results, nil +} + +type mockSonicPluginLoader struct { + provider sonic.Provider +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" && m.provider != nil { + return []string{"mock-sonic"} + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) { + if m.provider != nil { + return m.provider, true + } + return nil, false +} diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 24ecff1d6..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -102,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string { ID: id, Format: s.Format, BitRate: s.MaxBitRate, + ShareID: s.ID, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index daa09c375..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -4,11 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + if info.shareID != "" { + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + } + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -63,17 +77,14 @@ type shareTrackInfo struct { id string format string bitrate int + shareID string } func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - token, err := auth.TokenAuth.Decode(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } - if token == nil { - return shareTrackInfo{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } @@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { id: c.ID, format: c.Format, bitrate: c.BitRate, + shareID: c.ShareID, }, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go new file mode 100644 index 000000000..222d5ef1a --- /dev/null +++ b/server/public/handle_streams_test.go @@ -0,0 +1,196 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockStreamer struct { + req stream.Request + called bool +} + +func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) { + m.called = true + m.req = r + return nil, errors.New("mock: not implemented") +} + +var _ = Describe("decodeStreamInfo", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("decodes a valid token with all fields", func() { + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(192)) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("accepts a token without exp (non-expiring share)", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects a token without an id claim", func() { + claims := auth.Claims{ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an invalid token string", func() { + _, err := decodeStreamInfo("not-a-valid-token") + Expect(err).To(HaveOccurred()) + }) + + It("handles tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123", Format: "opus"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("opus")) + Expect(info.shareID).To(BeEmpty()) + }) +}) + +var _ = Describe("encodeMediafileShare", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("includes the share ID in the token", func() { + exp := P(time.Now().Add(time.Hour)) + s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} + token := encodeMediafileShare(s, "mf-999") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareABC")) + Expect(info.id).To(Equal("mf-999")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(320)) + }) + + It("creates a non-expiring token when share has no expiry", func() { + s := model.Share{ID: "shareXYZ", ExpiresAt: nil} + token := encodeMediafileShare(s, "mf-111") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareXYZ")) + Expect(info.id).To(Equal("mf-111")) + }) +}) + +var _ = Describe("handleStream", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var streamer *mockStreamer + var pub *Router + + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + streamer = &mockStreamer{} + pub = &Router{ds: ds, streamer: streamer} + }) + + makeRequest := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil) + w := httptest.NewRecorder() + pub.handleStream(w, r) + return w + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo + + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("returns 400 for an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 when share has been deleted", func() { + shareRepo.ID = "other-share" + claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when share has been set to expired", func() { + shareRepo.ID = "share123" + expired := time.Now().Add(-time.Hour) + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 500 when share lookup fails", func() { + shareRepo.Error = errors.New("db error") + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("skips share check for tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for an invalid token", func() { + w := makeRequest("not-a-valid-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/server/serve_index.go b/server/serve_index.go index 734aabc70..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 31bca02cf..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) It("sanitizes entity-encoded welcomeMessage as html", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 56cf469c5..2a2338d89 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -213,12 +213,16 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response.NowPlaying = &responses.NowPlaying{} var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ - Child: childFromMediaFile(ctx, np.MediaFile), - UserName: np.Username, - MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything - PlayerName: np.PlayerName, + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i, + PlayerName: np.PlayerName, + State: np.State, + PositionMs: np.PositionMs, + PlaybackRate: np.PlaybackRate, } }) return response, nil diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index ae4ef9bb9..220376b15 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e91c02aa4..0bbfcb83e 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -52,12 +53,14 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + sonic *sonicsvc.Sonic } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, + sonic *sonicsvc.Sonic, ) *Router { r := &Router{ ds: ds, @@ -75,6 +78,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea metrics: metrics, lyrics: lyrics, transcodeDecision: transcodeDecision, + sonic: sonic, } r.Handler = r.routes() return r @@ -121,6 +125,8 @@ func (api *Router) routes() http.Handler { h(r, "getTopSongs", api.GetTopSongs) h(r, "getSimilarSongs", api.GetSimilarSongs) h(r, "getSimilarSongs2", api.GetSimilarSongs2) + hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks) + hr(r, "findSonicPath", api.FindSonicPath) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -138,6 +144,7 @@ func (api *Router) routes() http.Handler { h(r, "star", api.Star) h(r, "unstar", api.Unstar) h(r, "scrobble", api.Scrobble) + h(r, "reportPlayback", api.ReportPlayback) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 3b88704b1..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() { It("updates status pointer when an error occurs", func() { pointer := int32(0) - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet r = r.WithContext(ctx) payload.Status = responses.StatusFailed diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 39bc83fa9..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "math" "net/http" "time" @@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi } log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) - return err + return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: trackId, + PositionMs: int64(position) * 1000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) +} + +func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + mediaId, err := p.String("mediaId") + if err != nil { + return nil, err + } + mediaType, err := p.String("mediaType") + if err != nil { + return nil, err + } + positionMs, err := p.Int64("positionMs") + if err != nil { + return nil, err + } + if positionMs < 0 { + return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative") + } + state, err := p.String("state") + if err != nil { + return nil, err + } + + if !scrobbler.ValidStates[state] { + return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state) + } + + playbackRate := p.Float64Or("playbackRate", 1.0) + if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 { + return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number") + } + ignoreScrobble := p.BoolOr("ignoreScrobble", false) + + ctx := r.Context() + if mediaType != "song" { + log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId) + } + + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mediaId, + PositionMs: positionMs, + State: state, + PlaybackRate: playbackRate, + IgnoreScrobble: ignoreScrobble, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err) + return nil, err + } + + return newResponse(), nil } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index e110e2b93..f5cf434e0 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -27,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { @@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying", func() { + It("registers a NowPlaying via ReportPlayback", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - Expect(playTracker.Playing).To(HaveLen(1)) - Expect(playTracker.Playing).To(HaveKey("player-1")) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12")) + Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1")) }) }) }) + + Describe("ReportPlayback", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when positionMs is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when state is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid state value", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative positionMs", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for NaN playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for Inf playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for zero playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("accepts mediaType=podcast without error", func() { + r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + resp, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + _, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0)) + Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse()) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1")) + Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - Playing map[string]string - Error error -} - -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { - if f.Error != nil { - return f.Error - } - if f.Playing == nil { - f.Playing = make(map[string]string) - } - f.Playing[playerId] = trackId - return nil + Submissions []scrobbler.Submission + ReportedPlayback []scrobbler.ReportPlaybackParams + Error error } func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { @@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm return nil } +func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error { + if f.Error != nil { + return f.Error + } + f.ReportedPlayback = append(f.ReportedPlayback, params) + return nil +} + var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) type fakeEventBroker struct { diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 3faae1650..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -37,7 +37,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses log.Warn(ctx, "User needs an email for gravatar to work", "username", username) return api.getPlaceHolderAvatar(w, r) } - http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) //nolint:gosec // URL is not constructed from user input return nil, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7f64fb47f..589a609da 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 2d8b1fd94..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -199,7 +199,7 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - cookie := &http.Cookie{ + cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, @@ -239,7 +239,9 @@ func playerIDCookieName(userName string) string { return cookieName } -const subsonicErrorPointer = "subsonicErrorPointer" +type contextKey string + +const subsonicErrorPointer contextKey = "subsonicErrorPointer" func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 353cf1077..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,12 +8,19 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, + {Name: "playbackReport", Versions: []int32{1}}, } + if api.sonic != nil && api.sonic.HasProvider() { + extensions = append(extensions, responses.OpenSubsonicExtension{ + Name: "sonicSimilarity", Versions: []int32{1}, + }) + } + response.OpenSubsonicExtensions = &extensions return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 92d1c3e84..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -5,12 +5,28 @@ import ( "net/http" "net/http/httptest" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +type mockSonicPluginLoader struct { + names []string +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonicsvc.Provider, bool) { + return nil, false +} + var _ = Describe("GetOpenSubsonicExtensions", func() { var ( router *subsonic.Router @@ -18,29 +34,66 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { r *http.Request ) - BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + JustBeforeEach(func() { w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) - It("should return the correct OpenSubsonicExtensions", func() { - router.ServeHTTP(w, r) + Context("without sonic similarity plugin", func() { + BeforeEach(func() { + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + }) - // Make sure the endpoint is public, by not passing any authentication - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { + router.ServeHTTP(w, r) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) + // Make sure the endpoint is public, by not passing any authentication + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(6), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 7 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(7), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + )) + }) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 3f2a2068e..1d5f6a70a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -258,7 +258,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/responses/.snapshots/.gitattributes b/server/subsonic/responses/.snapshots/.gitattributes new file mode 100644 index 000000000..44b4224b1 --- /dev/null +++ b/server/subsonic/responses/.snapshots/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON new file mode 100644 index 000000000..f99cb62af --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON @@ -0,0 +1,23 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "Song", + "username": "testuser", + "minutesAgo": 2, + "playerId": 1, + "playerName": "TestPlayer", + "state": "playing", + "positionMs": 120000, + "playbackRate": 1.5 + } + ] + } +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML new file mode 100644 index 000000000..ae05b68d0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML @@ -0,0 +1,5 @@ + + + + + diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON new file mode 100644 index 000000000..ab8f793fa --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": {} +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML new file mode 100644 index 000000000..763b4b583 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON new file mode 100644 index 000000000..b22f18e83 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON @@ -0,0 +1,25 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [ + { + "entry": { + "id": "1", + "isDir": false, + "title": "Bohemian Rhapsody" + }, + "similarity": 0.95 + }, + { + "entry": { + "id": "2", + "isDir": false, + "title": "We Will Rock You" + }, + "similarity": 0.78 + } + ] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML new file mode 100644 index 000000000..14d17d00d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML @@ -0,0 +1,10 @@ + + + + 0.95 + + + + 0.78 + + diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON new file mode 100644 index 000000000..75562871f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML new file mode 100644 index 000000000..184228a0e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML @@ -0,0 +1 @@ + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index f0bb26f66..b0b2b8752 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -62,6 +62,7 @@ type Subsonic struct { LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` + SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` } const ( @@ -357,10 +358,13 @@ type Starred2 struct { type NowPlayingEntry struct { Child - UserName string `xml:"username,attr" json:"username"` - MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` - PlayerId int32 `xml:"playerId,attr" json:"playerId"` - PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + UserName string `xml:"username,attr" json:"username"` + MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerId int32 `xml:"playerId,attr" json:"playerId"` + PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + State string `xml:"state,attr" json:"state"` + PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` + PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` } type NowPlaying struct { @@ -441,6 +445,11 @@ type TopSongs struct { Song []Child `xml:"song,omitempty" json:"song,omitempty"` } +type SonicMatch struct { + Entry Child `xml:"entry" json:"entry"` + Similarity float64 `xml:"similarity" json:"similarity"` +} + type PlayQueue struct { Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` Current string `xml:"current,attr,omitempty" json:"current,omitempty"` diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index e10957af2..7fc10aa15 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -1,14 +1,17 @@ package responses import ( + "fmt" + "os" + "path/filepath" "strings" "testing" - "github.com/bradleyjkemp/cupaloy/v2" "github.com/navidrome/navidrome/log" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/types" + "github.com/pmezard/go-difflib/difflib" ) func TestSubsonicApiResponses(t *testing.T) { @@ -18,25 +21,61 @@ func TestSubsonicApiResponses(t *testing.T) { } func MatchSnapshot() types.GomegaMatcher { - c := cupaloy.New(cupaloy.FailOnUpdate(false)) - return &snapshotMatcher{c} + return &snapshotMatcher{} } type snapshotMatcher struct { - c *cupaloy.Config + diff string } -func (matcher snapshotMatcher) Match(actual any) (success bool, err error) { - actualJson := strings.TrimSpace(string(actual.([]byte))) - err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) - success = err == nil - return +func (matcher *snapshotMatcher) Match(actual any) (success bool, err error) { + actualBytes, ok := actual.([]byte) + if !ok { + return false, fmt.Errorf("MatchSnapshot expects []byte, got %T", actual) + } + + name := ginkgo.CurrentSpecReport().FullText() + actualStr := strings.TrimSpace(string(actualBytes)) + + snapshotPath := filepath.Join(".snapshots", name) + + // Support UPDATE_SNAPSHOTS=true for `make snapshots` + if _, update := os.LookupEnv("UPDATE_SNAPSHOTS"); update { + err := os.MkdirAll(".snapshots", os.ModePerm) + if err != nil { + return false, err + } + return true, os.WriteFile(snapshotPath, []byte(actualStr+"\n"), 0600) + } + + expected, err := os.ReadFile(snapshotPath) + if err != nil { + return false, err + } + + // Normalize line endings so snapshots work on Windows (CRLF) and Unix (LF) + normalizedExpected := strings.ReplaceAll(string(expected), "\r\n", "\n") + normalizedExpected = strings.TrimSpace(normalizedExpected) + + if actualStr == normalizedExpected { + return true, nil + } + + diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(normalizedExpected), + B: difflib.SplitLines(actualStr), + FromFile: "snapshot", + ToFile: "actual", + Context: 3, + }) + matcher.diff = diff + return false, nil } -func (matcher snapshotMatcher) FailureMessage(_ any) (message string) { - return "Expected to match saved snapshot\n" +func (matcher *snapshotMatcher) FailureMessage(_ any) (message string) { + return "Expected to match saved snapshot\n" + matcher.diff } -func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) { +func (matcher *snapshotMatcher) NegatedFailureMessage(_ any) (message string) { return "Expected to not match saved snapshot\n" } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 15f2da9c6..ee98a3daa 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1,8 +1,3 @@ -//go:build unix - -// TODO Fix snapshot tests in Windows -// Response Snapshot tests. Only run in Linux and macOS, as they fail in Windows -// Probably because of EOL char differences package responses_test import ( @@ -1114,4 +1109,69 @@ var _ = Describe("Responses", func() { }) }) + Describe("NowPlaying", func() { + BeforeEach(func() { + response.NowPlaying = &NowPlaying{} + }) + + Describe("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + response.NowPlaying.Entry = []NowPlayingEntry{{ + Child: Child{Id: "1", Title: "Song", IsDir: false}, + UserName: "testuser", + MinutesAgo: 2, + PlayerId: 1, + PlayerName: "TestPlayer", + State: "playing", + PositionMs: 120000, + PlaybackRate: 1.5, + }} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("SonicMatches", func() { + Context("without data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{ + {Entry: Child{Id: "1", Title: "Bohemian Rhapsody", IsDir: false}, Similarity: 0.95}, + {Entry: Child{Id: "2", Title: "We Will Rock You", IsDir: false}, Similarity: 0.78}, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + }) diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index ab40a726f..9a9c6af6f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/sonic_similarity.go b/server/subsonic/sonic_similarity.go new file mode 100644 index 000000000..3640a116b --- /dev/null +++ b/server/subsonic/sonic_similarity.go @@ -0,0 +1,69 @@ +package subsonic + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) GetSonicSimilarTracks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + count := p.IntOr("count", 10) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func (api *Router) FindSonicPath(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + startSongID, err := p.String("startSongId") + if err != nil { + return nil, err + } + endSongID, err := p.String("endSongId") + if err != nil { + return nil, err + } + count := p.IntOr("count", 25) + + matches, err := api.sonic.FindSonicPath(ctx, startSongID, endSongID, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func sonicMatchResponse(ctx context.Context, matches []sonic.SimilarMatch) *responses.Subsonic { + response := newResponse() + resp := make(responses.Array[responses.SonicMatch], len(matches)) + for i, m := range matches { + resp[i] = responses.SonicMatch{ + Entry: childFromMediaFile(ctx, m.MediaFile), + Similarity: m.Similarity, + } + } + response.SonicMatches = &resp + return response +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 15ba168d7..adc7b7600 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -27,7 +27,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) w = httptest.NewRecorder() }) @@ -284,7 +284,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -301,7 +301,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9bdc52152..9b38ea5b5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -45,7 +45,7 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { +func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 22eb9446c..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -44,3 +44,16 @@ func (m *MockShareRepo) Exists(id string) (bool, error) { } return id == m.ID, nil } + +func (m *MockShareRepo) Get(id string) (*model.Share, error) { + if m.Error != nil { + return nil, m.Error + } + if id != m.ID { + return nil, model.ErrNotFound + } + if s, ok := m.Entity.(*model.Share); ok { + return s, nil + } + return &model.Share{ID: id}, nil +} diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 9717618fa..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -34,6 +34,7 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, + pointerEvents: 'none', textAlign: 'left', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', @@ -78,8 +79,9 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar': { + '&:hover $tileBar, &:focus-within $tileBar': { opacity: 1, + pointerEvents: 'auto', }, }, albumLink: { diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 28a24981c..0b8c256df 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -20,7 +20,6 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, - QuickFilter, Title, useAlbumsPerPage, useResourceRefresh, @@ -152,10 +151,9 @@ const AlbumFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 6c526a5a5..4559aa8e6 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -24,7 +24,6 @@ import { ArtistContextMenu, CoverArtAvatar, List, - QuickFilter, useGetHandleArtistClick, RatingField, useSelectedFields, @@ -88,10 +87,9 @@ const ArtistFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index eba3b82d7..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useInterval } from '../common' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -41,9 +42,11 @@ const Player = () => { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { useEffect(() => { const handleBeforeUnload = (e) => { - // Check there's a current track and is actually playing/not paused if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() - e.returnValue = '' // Chrome requires returnValue to be set + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -210,6 +246,7 @@ const Player = () => { audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: + playerState.queue.length > 0 && playerState.autoPlay !== false && (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, @@ -226,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const state = audioInstance?.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackId, posMs, state) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -274,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -308,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -368,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -396,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/config.js b/ui/src/config.js index f4d60dfba..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { useTranslate, Link, useNotify } from 'react-admin' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + }, + avatarContainer: { + position: 'relative', + flexShrink: 0, + width: theme.spacing(8), + height: theme.spacing(8), }, avatar: { - width: theme.spacing(6), - height: theme.spacing(6), + width: '100%', + height: '100%', cursor: 'pointer', + borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, + stateOverlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + borderRadius: theme.spacing(0.5), + pointerEvents: 'none', + }, + stateIcon: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 18, + }, + entryContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }, + trackTitle: { + fontWeight: 600, + fontSize: '0.875rem', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + trackDetail: { + fontSize: '0.75rem', + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + artistLink: { + cursor: 'pointer', + color: theme.palette.text.secondary, + fontSize: '0.75rem', + '&:hover': { + textDecoration: 'underline', + }, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + marginTop: theme.spacing(0.5), + }, + progressTime: { + fontSize: '0.65rem', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + }, + progressBar: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: theme.palette.action.disabledBackground, + '& .MuiLinearProgress-bar': { + borderRadius: 2, + }, + }, + userInfo: { + fontSize: '0.65rem', + color: theme.palette.text.disabled, + marginTop: theme.spacing(0.25), + }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + const isPaused = nowPlayingEntry.state === 'paused' + const isPlaying = + nowPlayingEntry.state === 'playing' || + nowPlayingEntry.state === 'starting' + const basePositionMs = nowPlayingEntry.positionMs || 0 + const rate = nowPlayingEntry.playbackRate || 1 + const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) + const interpolatedMs = isPlaying + ? basePositionMs + elapsedSinceFetch * rate + : basePositionMs + const durationMs = (nowPlayingEntry.duration || 0) * 1000 + const clampedMs = Math.max(0, interpolatedMs) + const positionMs = + durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs + const positionSec = positionMs / 1000 + const durationSec = nowPlayingEntry.duration || 0 + const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 + const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId + const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return ( - - + +
- - - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - ) : ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - )} -  - {nowPlayingEntry.title} + {isPaused && ( +
+
- } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} +
+
+ + {nowPlayingEntry.title} + + {artistId ? ( + + {artistName} + + ) : ( + + {artistName} + + )} + + {nowPlayingEntry.album} + +
+ + {formatDuration(positionSec)} + + + + {formatDuration(durationSec)} + +
+ + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + +
) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, album: PropTypes.string, + state: PropTypes.string, + positionMs: PropTypes.number, + duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( - ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( ))} @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { entries: PropTypes.arrayOf(PropTypes.object).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) + const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) const streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` }, []) - const fetchList = useCallback( - () => - subsonic - .getNowPlaying() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - const nowPlayingEntries = data.nowPlaying?.entry || [] - setEntries(nowPlayingEntries) - // Also update the count in Redux store - dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) - } else { - throw new Error( - data.error?.message || 'Failed to fetch now playing data', - ) - } + const fetchTimerRef = useRef(null) + const doFetchRef = useRef() + doFetchRef.current = () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + const fetchTime = Date.now() + setEntries( + nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), + ) + dispatch(nowPlayingCountSync({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + const fetchList = useCallback(() => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + fetchTimerRef.current = setTimeout(() => { + fetchTimerRef.current = null + doFetchRef.current() + }, 300) + }, []) + + useEffect(() => { + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) - // Refresh when count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [lastUpdate, open, fetchList, serverUp]) + + // Update current time every second when open to animate progress bars + useInterval(() => setNow(Date.now()), open ? 1000 : null) // Periodic refresh when panel is open (10 seconds) useInterval( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('', () => { , ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('', () => { , ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 8238e395a..d61ed6018 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -3,6 +3,7 @@ import { EVENT_SCAN_STATUS, EVENT_SERVER_START, EVENT_NOW_PLAYING_COUNT, + EVENT_NOW_PLAYING_COUNT_SYNC, EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -17,6 +18,7 @@ const initialState = { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, // Timestamp of last reconnection } @@ -45,6 +47,12 @@ export const activityReducer = (previousState = initialState, payload) => { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 466a3ec87..d6ab7484b 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -164,13 +164,15 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // Only keep clear and playIndex alive when there is an actual pending - // track switch (playIndex differs from savedPlayIndex). This lets - // PLAYER_PLAY_TRACKS selections survive the sync, while allowing - // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to - // reset immediately and avoid restarting playback. + // Keep clear and playIndex alive when there is a pending track switch. + // A switch is pending when playIndex is set AND either: + // - playIndex differs from savedPlayIndex, OR + // - clear is true (a new queue was loaded, e.g. after clearQueue + playTracks) + // The clear check handles the edge case where both playIndex and + // savedPlayIndex are 0 (close player then play a new album from track 1). const hasPendingSwitch = - state.playIndex != null && state.playIndex !== state.savedPlayIndex + state.playIndex != null && + (state.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 10e9512d7..110ce8c53 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -96,6 +96,88 @@ describe('playerReducer', () => { }) }) + describe('play new album after closing player (issue #5440)', () => { + it('SYNC_QUEUE preserves pending playIndex=0 after clearQueue', () => { + // Scenario: user plays album A, advances to track 3, closes player, + // then plays album B. After clearQueue, savedPlayIndex=0. + // PLAYER_PLAY_TRACKS sets playIndex=0. SYNC_QUEUE must NOT clear it. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, // reset by clearQueue + clear: true, + volume: 1, + } + + const action = { + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo: {}, + audioLists: stateAfterClearThenPlay.queue, + }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for wrong track preserves pending playIndex=0 after clearQueue', () => { + // The music player fires onAudioPlay for the old track (at index 3) + // before switching to the new track at index 0. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + { trackId: 'b4', uuid: 'u4', name: 'B Song 4' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player reports track at index 3 as current (stale callback) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u4', name: 'B Song 4', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for correct track consumes pending playIndex=0', () => { + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player confirms it switched to track at index 0 + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u1', name: 'B Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 98684132f..d928af549 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -20,7 +20,6 @@ import { SongContextMenu, SongDatagrid, SongInfo, - QuickFilter, SongTitleField, SongSimpleList, RatingField, @@ -119,10 +118,9 @@ const SongFilter = (props) => { /> {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index a750694f4..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -194,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +}) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index cac41be7b..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface { AddWithTTL(key K, value V, ttl time.Duration) error Get(key K) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) + Remove(key K) Keys() []K Values() []V Len() int @@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error return nil } +func (c *simpleCache[K, V]) Remove(key K) { + c.data.Delete(key) +} + func (c *simpleCache[K, V]) Get(key K) (V, error) { item := c.data.Get(key) if item == nil { diff --git a/utils/req/req.go b/utils/req/req.go index f9fa5724b..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool { } return v } + +func (r *Values) Float64Or(param string, def float64) float64 { + v, err := r.String(param) + if err != nil { + return def + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def + } + return f +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index e710365bd..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() { }) }) + Describe("Float64Or", func() { + It("returns parsed float value", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5)) + }) + + It("returns default when param is missing", func() { + r := req.Params(httptest.NewRequest("GET", "/test", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + + It("returns default when param is not a valid float", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + }) + Describe("ParamBoolPtr", func() { Context("value is true", func() { BeforeEach(func() { diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index c121aefe7..11f828270 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -52,6 +52,22 @@ func SanitizeHTML(text string) string { return policy.Sanitize(html.UnescapeString(text)) } +var filenameReplacer = strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", +) + +func SanitizeFilename(name string) string { + return filenameReplacer.Replace(name) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v))