diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 076f963d4..5b421331b 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 86a1055f8..8e6e8126a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -166,7 +166,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 @@ -323,7 +323,7 @@ jobs: - name: Set up QEMU for smoke test if: env.IS_LINUX == 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # The binary is static, so binfmt+qemu runs it directly on the runner. # Catches startup crashes in cross-compiled binaries before they ship, diff --git a/cmd/root.go b/cmd/root.go index 08773176a..9e2b38cd8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startPlaybackServer(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBOptimizer(ctx)) + g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { @@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error { if conf.Server.ListenBrainz.Enabled { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } + if conf.Server.Jellyfin.Enabled { + a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) + } if conf.Server.Prometheus.Enabled { p := CreatePrometheus() // blocking call because takes <100ms but useful if fails @@ -275,16 +278,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error { } } -func scheduleDBOptimizer(ctx context.Context) func() error { +func scheduleDBAnalyzer(ctx context.Context) func() error { return func() error { - log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) + if !conf.Server.EnableScheduledDBAnalyze { + log.Info(ctx, "Scheduled DB analysis is DISABLED") + return nil + } + log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { - if scanner.IsScanning() { - log.Debug(ctx, "Skipping DB optimization because a scan is in progress") + _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { + release, ok := scanner.LockForMaintenance() + if !ok { + log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") return } - db.Optimize(ctx) + defer release() + if _, err := db.OptimizeIfNeeded(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } }) return err } diff --git a/cmd/scan.go b/cmd/scan.go index d8a563396..320b401d4 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/gob" + "errors" "fmt" "os" "strings" @@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{ }, } -func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { +func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) { + var changesDetected bool + var scanErrors []error for status := range pl.ReadOrDone(ctx, progress) { if status.Warning != "" { log.Warn(ctx, "Scan warning", "error", status.Warning) } if status.Error != "" { log.Error(ctx, "Scan error", "error", status.Error) + scanErrors = append(scanErrors, errors.New(status.Error)) + } + if status.ChangesDetected { + changesDetected = true } - // Discard the progress status, we only care about errors } if fullScan { @@ -59,6 +65,7 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre } else { log.Info("Finished rescan") } + return changesDetected, errors.Join(scanErrors...) } func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { @@ -95,6 +102,16 @@ func runScanner(ctx context.Context) { log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) } + effectiveFullScan := fullScan + if !subprocess { + effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets) + if effectiveFullScan { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + } + progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) @@ -104,7 +121,21 @@ func runScanner(ctx context.Context) { if subprocess { trackScanAsSubprocess(ctx, progress) } else { - trackScanInteractively(ctx, progress) + changesDetected, scanErr := trackScanInteractively(ctx, progress) + runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr) + } +} + +func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) { + if changesDetected { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + if effectiveFullScan && scanErr == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index beeecca19..309d09f98 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -1,14 +1,29 @@ package cmd import ( + "context" "os" "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("trackScanInteractively", func() { + It("reports changes and scan errors", func() { + progress := make(chan *scanner.ProgressInfo, 2) + progress <- &scanner.ProgressInfo{ChangesDetected: true} + progress <- &scanner.ProgressInfo{Error: "scan failed"} + close(progress) + + changesDetected, err := trackScanInteractively(context.Background(), progress) + Expect(changesDetected).To(BeTrue()) + Expect(err).To(MatchError("scan failed")) + }) +}) + var _ = Describe("readTargetsFromFile", func() { var tempDir string diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6ffc44d4..bd211cbfc 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -31,6 +31,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -116,6 +117,30 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { return router } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + players := core.NewPlayers(dataStore) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) + return router +} + func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -221,7 +246,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, 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))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.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(sonic.Engine), new(*sonic.Sonic)), 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 bb5c5b5f3..94faa5af3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -23,6 +23,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -33,6 +34,7 @@ var allProviders = wire.NewSet( artwork.Set, server.New, subsonic.New, + jellyfin.New, nativeapi.New, public.New, persistence.New, @@ -49,6 +51,7 @@ var allProviders = wire.NewSet( 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(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), @@ -79,6 +82,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { )) } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + panic(wire.Build( + allProviders, + )) +} + func CreatePublicRouter() *public.Router { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index 8646bf075..83793bd43 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -51,6 +51,7 @@ type configOptions struct { EnableExternalServices bool EnableM3UExternalAlbumArt bool EnableInsightsCollector bool + EnableScheduledDBAnalyze bool EnableMediaFileCoverArt bool TranscodingCacheSize string ImageCacheSize string @@ -116,6 +117,7 @@ type configOptions struct { LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` + Jellyfin jellyfinOptions `json:",omitzero"` EnableScrobbleHistory bool Tags map[string]TagConf `json:",omitempty"` Agents string @@ -147,7 +149,6 @@ type configOptions struct { DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 - DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool } @@ -218,6 +219,18 @@ type listenBrainzOptions struct { TrackAlgorithm string } +type jellyfinOptions struct { + Enabled bool + ServerName string + // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated + // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. + ExposedPublicUsers string + // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB + // cursor — and its pooled connection — for the whole client-paced response, so without a bound + // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. + MaxConcurrentStreams int +} + type httpHeaderOptions struct { FrameOptions string } @@ -800,6 +813,7 @@ func setViperDefaults() { viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) + viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) @@ -848,6 +862,8 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) + viper.SetDefault("jellyfin.enabled", false) + viper.SetDefault("jellyfin.servername", "") viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") @@ -877,6 +893,9 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) + // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, + // scrobbles and the UI. See MaxOpenConns. + viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2)) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) @@ -891,7 +910,6 @@ func setViperDefaults() { viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) - viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) } @@ -948,3 +966,14 @@ func getConfigFile(cfgFile string) string { } return "" } + +// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner, +// Subsonic, Jellyfin, native API, UI). +// +// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so +// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a +// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the +// count is only loosely related to core count, and the floor is what matters on small machines. +func MaxOpenConns() int { + return max(4, runtime.NumCPU()) +} diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 9c25a0d19..e43c91a4b 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -58,6 +58,19 @@ var _ = Describe("Configuration", func() { }) }) + Describe("scheduled DB analysis", func() { + It("is enabled by default", func() { + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) + }) + + It("can be disabled", func() { + viper.Set("enablescheduleddbanalyze", false) + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) + }) + }) + Describe("ValidateURL", func() { It("accepts a valid http URL", func() { fn := conf.ValidateURL("TestOption", "http://example.com/path") diff --git a/consts/consts.go b/consts/consts.go index 3795b590a..f453ac125 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -20,6 +20,10 @@ const ( LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" + LastDBAnalyzeAtKey = "LastDBAnalyzeAt" + LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" + DBAnalyzePendingKey = "DBAnalyzePending" + DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -28,7 +32,8 @@ const ( DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - OptimizeDBSchedule = "@every 24h" + DBAnalyzeCheckSchedule = "@every 30m" + DBAnalyzeMaxAge = 24 * time.Hour // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option @@ -44,6 +49,11 @@ const ( URLPathSubsonicAPI = "/rest" URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" + URLPathJellyfinAPI = "/jellyfin" + + // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the + // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. + JellyfinServerIDKey = "JellyfinServerID" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome diff --git a/core/image_upload.go b/core/image_upload.go index c2432b647..eb61b225a 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -17,6 +20,16 @@ type ImageUploadService interface { RemoveImage(ctx context.Context, path string) error } +// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default +// when it's unset/invalid. Shared by every API that accepts image uploads. +func MaxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} + type imageUploadService struct{} func NewImageUploadService() ImageUploadService { diff --git a/core/image_upload_test.go b/core/image_upload_test.go index 265f60a95..e7648df34 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -97,3 +97,29 @@ var _ = Describe("ImageUploadService", func() { }) }) }) + +var _ = Describe("MaxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index bcd0343c2..78391779a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -223,6 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 34648a49b..126d759bc 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -43,45 +43,46 @@ type Data struct { FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"` } `json:"library"` Config struct { - LogLevel string `json:"logLevel,omitempty"` - LogFileConfigured bool `json:"logFileConfigured,omitempty"` - TLSConfigured bool `json:"tlsConfigured,omitempty"` - ScannerEnabled bool `json:"scannerEnabled,omitempty"` - ScannerExtractor string `json:"scannerExtractor,omitempty"` - ScanSchedule string `json:"scanSchedule,omitempty"` - ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` - ScanOnStartup bool `json:"scanOnStartup,omitempty"` - TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` - ImageCacheSize string `json:"imageCacheSize,omitempty"` - EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` - EnableDownloads bool `json:"enableDownloads,omitempty"` - EnableSharing bool `json:"enableSharing,omitempty"` - EnableStarRating bool `json:"enableStarRating,omitempty"` - EnableLastFM bool `json:"enableLastFM,omitempty"` - EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` - EnableDeezer bool `json:"enableDeezer,omitempty"` - EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` - EnableJukebox bool `json:"enableJukebox,omitempty"` - EnablePrometheus bool `json:"enablePrometheus,omitempty"` - EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` - CoverArtQuality int `json:"coverArtQuality,omitempty"` - EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` - UICoverArtSize int `json:"uiCoverArtSize,omitempty"` - EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` - EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` - SessionTimeout uint64 `json:"sessionTimeout,omitempty"` - SearchFullString bool `json:"searchFullString,omitempty"` - SearchBackend string `json:"searchBackend,omitempty"` - RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` - PreferSortTags bool `json:"preferSortTags,omitempty"` - BackupSchedule string `json:"backupSchedule,omitempty"` - BackupCount int `json:"backupCount,omitempty"` - DevActivityPanel bool `json:"devActivityPanel,omitempty"` - DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` - HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` - ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` - HasCustomPID bool `json:"hasCustomPID,omitempty"` - HasCustomTags bool `json:"hasCustomTags,omitempty"` + LogLevel string `json:"logLevel,omitempty"` + LogFileConfigured bool `json:"logFileConfigured,omitempty"` + TLSConfigured bool `json:"tlsConfigured,omitempty"` + ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScannerExtractor string `json:"scannerExtractor,omitempty"` + ScanSchedule string `json:"scanSchedule,omitempty"` + ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` + ScanOnStartup bool `json:"scanOnStartup,omitempty"` + EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"` + TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` + ImageCacheSize string `json:"imageCacheSize,omitempty"` + EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` + EnableDownloads bool `json:"enableDownloads,omitempty"` + EnableSharing bool `json:"enableSharing,omitempty"` + EnableStarRating bool `json:"enableStarRating,omitempty"` + EnableLastFM bool `json:"enableLastFM,omitempty"` + EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` + EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` + EnableJukebox bool `json:"enableJukebox,omitempty"` + EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` + EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` + SessionTimeout uint64 `json:"sessionTimeout,omitempty"` + SearchFullString bool `json:"searchFullString,omitempty"` + SearchBackend string `json:"searchBackend,omitempty"` + RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` + PreferSortTags bool `json:"preferSortTags,omitempty"` + BackupSchedule string `json:"backupSchedule,omitempty"` + BackupCount int `json:"backupCount,omitempty"` + DevActivityPanel bool `json:"devActivityPanel,omitempty"` + DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` Plugins map[string]PluginInfo `json:"plugins,omitempty"` } diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 3da24706c..1ef083bbb 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -22,6 +22,7 @@ type Playlists interface { GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) Get(ctx context.Context, id string) (*model.Playlist, error) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) + Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) // Mutations @@ -98,6 +99,21 @@ func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) } +// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather +// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards +// its error behind a nil (and warns), and this is probed with ids that are usually not playlists. +func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { + repo := s.ds.Playlist(ctx) + if _, err := repo.Get(id); err != nil { + return nil, err + } + tracks := repo.Tracks(id, true) + if tracks == nil { + return nil, model.ErrNotFound + } + return tracks, nil +} + // --- Mutation operations --- // Create creates a new playlist (when name is provided) or replaces tracks on an existing diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index f849a0a21..0c9674bed 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -73,6 +73,28 @@ var _ = Describe("Playlists", func() { }) }) + Describe("Tracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + }) + + It("returns the playlist's track repository", func() { + Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks)) + }) + + It("returns ErrNotFound for an unknown or invisible playlist", func() { + _, err := ps.Tracks(ctx, "nonexistent") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Describe("Create", func() { BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go index 19eb69c65..67f5cc7da 100644 --- a/core/sonic/sonic.go +++ b/core/sonic/sonic.go @@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher } } +// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. +type Engine interface { + HasProvider() bool + GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) + FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) +} + +var _ Engine = (*Sonic)(nil) + func (s *Sonic) HasProvider() bool { return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 } diff --git a/db/db.go b/db/db.go index 6e5b2f569..4ca996fe5 100644 --- a/db/db.go +++ b/db/db.go @@ -5,7 +5,7 @@ import ( "database/sql" "embed" "fmt" - "runtime" + "time" "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" @@ -43,16 +43,10 @@ func Db() *sql.DB { } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) - db.SetMaxOpenConns(max(4, runtime.NumCPU())) + db.SetMaxOpenConns(conf.MaxOpenConns()) if err != nil { log.Fatal("Error opening database", err) } - if conf.Server.DevOptimizeDB { - _, err = db.Exec("PRAGMA optimize=0x10002") - if err != nil { - log.Error("Error applying PRAGMA optimize", err) - } - } return db }) } @@ -61,9 +55,6 @@ func Close(ctx context.Context) { // Ignore cancellations when closing the DB ctx = context.WithoutCancel(ctx) - // Run optimize before closing - Optimize(ctx) - log.Info(ctx, "Closing Database") err := Db().Close() if err != nil { @@ -102,11 +93,11 @@ func Init(ctx context.Context) func() { log.Fatal(ctx, "Failed to apply new migrations", err) } - if hasSchemaChanges && conf.Server.DevOptimizeDB { - log.Debug(ctx, "Applying PRAGMA optimize after schema changes") - _, err = db.ExecContext(ctx, "PRAGMA optimize") + if hasSchemaChanges { + log.Debug(ctx, "Running ANALYZE after schema changes") + err = optimizeAt(ctx, db, time.Now()) if err != nil { - log.Error(ctx, "Error applying PRAGMA optimize", err) + log.Error(ctx, "Error running ANALYZE", err) } } @@ -115,37 +106,6 @@ func Init(ctx context.Context) func() { } } -// Optimize runs PRAGMA optimize on each connection in the pool -func Optimize(ctx context.Context) { - if !conf.Server.DevOptimizeDB { - return - } - numConns := Db().Stats().OpenConnections - if numConns == 0 { - log.Debug(ctx, "No open connections to optimize") - return - } - log.Debug(ctx, "Optimizing open connections", "numConns", numConns) - var conns []*sql.Conn - for range numConns { - conn, err := Db().Conn(ctx) - conns = append(conns, conn) - if err != nil { - log.Error(ctx, "Error getting connection from pool", err) - continue - } - _, err = conn.ExecContext(ctx, "PRAGMA optimize;") - if err != nil { - log.Error(ctx, "Error running PRAGMA optimize", err) - } - } - - // Return all connections to the Connection Pool - for _, conn := range conns { - conn.Close() - } -} - type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/export_test.go b/db/export_test.go index 734a4462f..02b88cd66 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,6 +2,9 @@ package db // Definitions for testing private methods var ( - IsSchemaEmpty = isSchemaEmpty - BackupPath = backupPath + IsSchemaEmpty = isSchemaEmpty + BackupPath = backupPath + OptimizeDBAt = optimizeAt + OptimizeDBIfNeeded = optimizeIfNeeded + RecordAnalyzeFailure = recordAnalyzeFailure ) diff --git a/db/migrations/20260714120000_add_playlist_average_rating.sql b/db/migrations/20260714120000_add_playlist_average_rating.sql new file mode 100644 index 000000000..5db642986 --- /dev/null +++ b/db/migrations/20260714120000_add_playlist_average_rating.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE playlist DROP COLUMN average_rating; diff --git a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql new file mode 100644 index 000000000..18666eef9 --- /dev/null +++ b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin + +-- Covering index for the title-sorted, library-scoped song listing: +-- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m +-- (Jellyfin clients page through the whole library this way; non-admin native and +-- Subsonic song lists produce the same shape.) +-- +-- Without it, SQLite walks media_file_order_title and must fetch the table row for +-- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit +-- random row reads (seconds on cold spinning disks). With the filter columns in the +-- index the skip is index-only. `id` is included because the annotation/bookmark +-- LEFT JOINs run per candidate row and need the join key; without it each skipped +-- entry still triggers a row fetch. +create index if not exists media_file_missing_library_order_title + on media_file(missing, library_id, order_title, id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop index if exists media_file_missing_library_order_title; +-- +goose StatementEnd diff --git a/db/migrations/migration.go b/db/migrations/migration.go index 9b1098af1..df1c392a5 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" ) @@ -21,13 +20,6 @@ func notice(ctx context.Context, tx *sql.Tx, msg string) { // Call this in migrations that requires a full rescan func forceFullRescan(ctx context.Context, tx *sql.Tx) error { - // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. - if conf.Server.DevOptimizeDB { - _, err := tx.ExecContext(ctx, `ANALYZE;`) - if err != nil { - return err - } - } _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) diff --git a/db/optimize.go b/db/optimize.go new file mode 100644 index 000000000..f46906c4e --- /dev/null +++ b/db/optimize.go @@ -0,0 +1,224 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" +) + +var analyzeMux sync.Mutex + +// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided +// because its limited analysis misestimates Navidrome's low-cardinality indexes. +func Optimize(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + if err := optimizeAt(ctx, Db(), start); err != nil { + return err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return nil +} + +// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation +// marked them for refresh. +func OptimizeIfNeeded(ctx context.Context) (bool, error) { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + ran, err := optimizeIfNeeded(ctx, Db(), start) + if err != nil || !ran { + return ran, err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return true, nil +} + +func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + due, err := optimizeDue(ctx, db, now) + if err != nil || !due { + return false, err + } + return true, optimizeAt(ctx, db, now) +} + +func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + backingOff, err := analyzeRetryBackoffActive(ctx, db, now) + if err != nil || backingOff { + return false, err + } + + pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey) + if err != nil { + return false, err + } + if found && pending == "1" { + return true, nil + } + + value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey) + if err != nil { + return false, err + } + if !found { + return true, nil + } + + lastAnalyze, valid := parseAnalyzeTime(value) + if !valid || lastAnalyze.After(now) { + return true, nil + } + return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil +} + +func parseAnalyzeTime(value string) (time.Time, bool) { + parsed, err := time.Parse(time.RFC3339Nano, value) + return parsed, err == nil +} + +func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey) + if err != nil || !found { + return false, err + } + failures, _ := strconv.Atoi(value) + if failures < 1 { + return false, nil + } + + value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey) + if err != nil || !found { + return false, err + } + lastAttempt, valid := parseAnalyzeTime(value) + if !valid || lastAttempt.After(now) { + return false, nil + } + return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil +} + +func analyzeRetryDelay(failures int) time.Duration { + switch failures { + case 1: + return 30 * time.Minute + case 2: + return time.Hour + case 3: + return 2 * time.Hour + default: + return 24 * time.Hour + } +} + +// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check. +func MarkOptimizePending(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + return markOptimizePending(ctx, Db()) +} + +func markOptimizePending(ctx context.Context, db *sql.DB) error { + return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1") +} + +func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error { + if err := markOptimizePending(ctx, db); err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err)) + } + log.Debug(ctx, "Refreshing query planner statistics") + _, err := db.ExecContext(ctx, "ANALYZE") + if err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err)) + } + if err = recordAnalyzeSuccess(ctx, db, now); err != nil { + return recordAnalyzeError(ctx, db, now, err) + } + return nil +} + +func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil { + return fmt.Errorf("clearing pending ANALYZE: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil { + return fmt.Errorf("clearing ANALYZE failure count: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("recording ANALYZE state: %w", err) + } + return nil +} + +func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error { + if err := recordAnalyzeFailure(ctx, db, now); err != nil { + return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err)) + } + return analyzeErr +} + +func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey) + if err != nil { + return err + } + failures := 0 + if found { + failures, _ = strconv.Atoi(value) + failures = max(failures, 0) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +type sqlExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +type sqlQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func putProperty(ctx context.Context, db sqlExecer, key, value string) error { + _, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + return err +} + +func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) { + var value string + err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return value, err == nil, err +} diff --git a/db/optimize_test.go b/db/optimize_test.go new file mode 100644 index 000000000..da9b3b9c9 --- /dev/null +++ b/db/optimize_test.go @@ -0,0 +1,162 @@ +package db_test + +import ( + "context" + "database/sql" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/db" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Optimize", func() { + var ( + ctx context.Context + database *sql.DB + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC) + var err error + database, err = sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(database.Close) + + _, err = database.Exec(`create table property( + id varchar(255) primary key, + value varchar(255) not null default '' + )`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create table analyze_probe(id integer primary key, flag int)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec(`insert into analyze_probe(flag) + with recursive s(x) as (select 1 union all select x+1 from s where x < 3000) + select 0 from s`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create index probe_flag on analyze_probe(flag)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("analyze") + Expect(err).ToNot(HaveOccurred()) + }) + + putProperty := func(key, value string) { + _, err := database.Exec(`insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + Expect(err).ToNot(HaveOccurred()) + } + + getProperty := func(key string) string { + var value string + Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed()) + return value + } + + poisonStats := func() { + _, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'") + Expect(err).ToNot(HaveOccurred()) + } + + It("replaces poisoned planner statistics with full-quality ones", func() { + poisonStats() + putProperty(consts.DBAnalyzePendingKey, "1") + + Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) + + var stat string + err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat) + Expect(err).ToNot(HaveOccurred()) + // A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count. + Expect(stat).To(Equal("3000 3000")) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("runs when no previous analysis was recorded", func() { + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("skips a recent analysis when no refresh is pending", func() { + lastAnalyze := now.Add(-23 * time.Hour) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + poisonStats() + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano))) + + var stat string + Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed()) + Expect(stat).To(Equal("3000 50")) + }) + + It("runs when the previous analysis is stale", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("runs when a refresh is pending even if the previous analysis is recent", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "1") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + DescribeTable("backs off after consecutive analysis failures", + func(failures string, retryDelay time.Duration) { + putProperty(consts.DBAnalyzePendingKey, "1") + putProperty(consts.DBAnalyzeFailureCountKey, failures) + putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano)) + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + + ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0")) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }, + Entry("for 30 minutes after the first failure", "1", 30*time.Minute), + Entry("for one hour after the second failure", "2", time.Hour), + Entry("for two hours after the third failure", "3", 2*time.Hour), + Entry("for 24 hours after the fourth failure", "4", 24*time.Hour), + ) + + It("records consecutive analysis failures", func() { + putProperty(consts.DBAnalyzeFailureCountKey, "2") + + Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed()) + + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3")) + Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + + It("does not record success when analysis fails", func() { + lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze) + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled"))) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) + }) +}) diff --git a/go.mod b/go.mod index 71aabdcd7..014a43a56 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.47 + github.com/mattn/go-sqlite3 v1.14.48 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 @@ -59,12 +59,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.43.0 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 - golang.org/x/term v0.44.0 - golang.org/x/text v0.39.0 + golang.org/x/image v0.44.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 + golang.org/x/text v0.40.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -75,7 +75,7 @@ require ( github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/reflex v0.3.1 // indirect + github.com/cespare/reflex v0.3.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect + github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index ec532b0a0..064974edb 100644 --- a/go.sum +++ b/go.sum @@ -16,13 +16,12 @@ 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/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= -github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= +github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= +github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,7 +61,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= @@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -143,11 +141,8 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -174,8 +169,8 @@ 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.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.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= -github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -309,39 +304,38 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/log/log.go b/log/log.go index eaea75fb9..1c4ee3b4b 100644 --- a/log/log.go +++ b/log/log.go @@ -45,8 +45,10 @@ var redacted = &Hook{ "([^\\w]p=)[^&]+", "([^\\w]jwt=)[^&]+", - // External services query params - "([^\\w]api_key=)[\\w]+", + // External services query params. Values can be JWTs (dots, dashes), so match everything up + // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop + // at a JWT's first '.' and leak its payload and signature. + "([^\\w]api_key=)[^&\\s]+", }, } diff --git a/log/log_test.go b/log/log_test.go index 7e1f3f3cc..7b6ecfc32 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -259,5 +259,10 @@ var _ = Describe("Logger", func() { msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title" Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title")) }) + + It("redacts a whole JWT in api_key, not just up to its first dot", func() { + msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) + }) }) }) diff --git a/model/album.go b/model/album.go index 667f4695b..ade7f6ee0 100644 --- a/model/album.go +++ b/model/album.go @@ -141,6 +141,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetCursor(...QueryOptions) (AlbumCursor, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/artist.go b/model/artist.go index 2085f0051..f9c4bffd5 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "maps" "slices" "time" @@ -79,6 +80,8 @@ type ArtistIndex struct { } type ArtistIndexes []ArtistIndex +type ArtistCursor iter.Seq2[Artist, error] + type ArtistRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) @@ -86,6 +89,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: diff --git a/model/playlist.go b/model/playlist.go index dc549f039..40adb8d0a 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "slices" "strconv" "time" @@ -10,6 +11,8 @@ import ( ) type Playlist struct { + Annotations `structs:"-"` + ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` Comment string `structs:"comment" json:"comment"` @@ -119,14 +122,18 @@ func (pls Playlist) UploadedImagePath() string { type Playlists []Playlist +type PlaylistCursor iter.Seq2[Playlist, error] + type PlaylistRepository interface { ResourceRepository + AnnotatedRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, 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) + GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository @@ -150,10 +157,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { return mfs } +type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] + type PlaylistTrackRepository interface { ResourceRepository + CountAll(options ...QueryOptions) (int64, error) GetAll(options ...QueryOptions) (PlaylistTracks, error) + GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error) GetAlbumIDs(options ...QueryOptions) ([]string, error) + GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 34845be15..6ebbd9202 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -247,6 +247,15 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e return res.toModels(), nil } +func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { + sq := r.selectAlbum(options...) + cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) + if err != nil { + return nil, err + } + return wrapAlbumCursor(cursor), nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) @@ -319,17 +328,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) } func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { - return func(yield func(model.Album, error) bool) { - for a, err := range cursor { - if a.Album == nil { - yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) - return - } - if !yield(*a.Album, err) || err != nil { - return - } - } - } + return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album })) } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index f72f778db..64ff0095e 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -67,6 +67,22 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same albums as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) @@ -854,7 +870,7 @@ var _ = Describe("AlbumRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f84f410e9..b542dedb4 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "os" "slices" "strings" @@ -263,6 +264,19 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } +func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + sel := r.selectArtist(options...) + cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapArtistCursor(cursor), nil +} + +func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor { + return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist })) +} + func (r *artistRepository) getIndexKey(a model.Artist) string { source := a.OrderArtistName if conf.Server.PreferSortTags { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index d7b695ade..dc11ede36 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -268,6 +268,22 @@ var _ = Describe("ArtistRepository", func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same artists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 8fb7f0296..5da395a74 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -263,17 +263,7 @@ func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { } func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { - return func(yield func(model.Folder, error) bool) { - for f, err := range cursor { - if f.Folder == nil { - yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) - return - } - if !yield(*f.Folder, err) || err != nil { - return - } - } - } + return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) } func (r folderRepository) purgeEmpty(libraryIDs ...int) error { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index a8945dfee..8cd45f16b 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -297,7 +297,7 @@ var _ = Describe("FolderRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 3789a71c9..5a0142423 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -173,15 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error { Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) _, err := r.executeSQL(sq) - if err != nil { - return err - } - // https://www.sqlite.org/pragma.html#pragma_optimize - // Use mask 0x10000 to check table sizes without running ANALYZE - // Running ANALYZE can cause query planner issues with expression-based collation indexes - if conf.Server.DevOptimizeDB { - _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) - } return err } diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index b4979ca77..ace61610c 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -420,17 +420,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC } func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { - return func(yield func(model.MediaFile, error) bool) { - for m, err := range cursor { - if m.MediaFile == nil { - yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) - return - } - if !yield(*m.MediaFile, err) || err != nil { - return - } - } - } + return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile })) } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 80d440c41..f6a744d8d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -29,6 +29,22 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same media files as GetAll", func() { + opts := model.QueryOptions{Sort: "title"} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + }) + It("gets mediafile from the DB", func() { actual, err := mr.Get("1004") Expect(err).ToNot(HaveOccurred()) @@ -1012,7 +1028,7 @@ var _ = Describe("MediaRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 1164eb70f..93f0e3e71 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -193,6 +193,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), + trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }), trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }), trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }), trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 4f2fd7fe2..f146cb06b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -329,3 +329,16 @@ var _ = BeforeSuite(func() { func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } + +// collectCursor takes the cursor's underlying func type so the named cursor types +// (model.AlbumCursor, ...) infer T. +func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { + GinkgoHelper() + Expect(err).ToNot(HaveOccurred()) + var out []T + for item, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + out = append(out, item) + } + return out +} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 4152505d2..e39f0bbd3 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "slices" "time" @@ -85,8 +86,11 @@ func (r *playlistRepository) userFilter() Sqlizer { } func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sq := Select().Where(r.userFilter()) - return r.count(sq, options...) + query := Select().Where(r.userFilter()) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "playlist.id") + } + return r.count(query, options...) } func (r *playlistRepository) Exists(id string) (bool, error) { @@ -183,6 +187,21 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. + sel := r.selectPlaylist(options...).Where(r.userFilter()) + cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapPlaylistCursor(cursor), nil +} + +// dbPlaylist embeds a value, not a pointer, so its model is never nil. +func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor { + return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist })) +} + func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). @@ -203,8 +222,9 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, } func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user on user.id = owner_id"). + sel := r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") + return r.withAnnotation(sel, r.tableName+".id") } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { @@ -278,10 +298,11 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { return nil } -func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { - sel = r.applyLibraryFilter(sel, "f") +// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. +func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder { + query = r.applyLibraryFilter(query, "f") userID := loggedUser(r.ctx).ID - tracksQuery := sel. + return query. Columns( "coalesce(starred, 0) as starred", "starred_at", @@ -301,8 +322,11 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). Where(Eq{"playlist_id": id}) +} + +func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) { tracks := dbPlaylistTracks{} - err := r.queryAll(tracksQuery, &tracks) + err := r.queryAll(r.tracksQuery(query, id), &tracks) if err != nil { return nil, err } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index cfabd0983..c51ff6222 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,11 +1,15 @@ package persistence import ( + "slices" + + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -23,6 +27,15 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same playlists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want))) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) @@ -71,6 +84,111 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Annotations", func() { + var plsID string + + BeforeEach(func() { + pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + plsID = pls.ID + }) + + countAnnotations := func() int { + var count int + Expect(GetDBXBuilder().NewQuery( + "SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}"). + Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed()) + return count + } + + It("stores and reads back starred", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeTrue()) + Expect(p.StarredAt).ToNot(BeNil()) + }) + + It("stores and reads back rating and average_rating", func() { + Expect(repo.SetRating(4, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Rating).To(Equal(4)) + Expect(p.RatedAt).ToNot(BeNil()) + Expect(p.AverageRating).To(Equal(4.0)) + }) + + It("keeps annotations isolated per user", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), + model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) + otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + + p, err := otherRepo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + }) + + It("reads starred back through GetAll", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) + Expect(idx).To(BeNumerically(">=", 0)) + Expect(all[idx].Starred).To(BeTrue()) + }) + + It("counts playlists using annotation filters", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} + starred, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + + count, err := repo.CountAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(len(starred)))) + }) + + It("does not leak an annotation row of another item_type sharing the playlist id", func() { + // Older builds (and the star fallthrough) can leave a media_file-typed row + // under a playlist id; the item_type-scoped join must not surface or dupe it. + _, err := GetDBXBuilder().NewQuery( + "INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)"). + Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + matches := 0 + for _, pl := range all { + if pl.ID == plsID { + matches++ + } + } + Expect(matches).To(Equal(1)) + }) + + It("relies on the annotation sweep, not Delete, to clean up annotations", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + Expect(repo.Delete(plsID)).To(Succeed()) + Expect(countAnnotations()).To(Equal(1)) + + Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed()) + Expect(countAnnotations()).To(Equal(0)) + }) + }) + It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a7062cc2..e51ff8ea6 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -77,6 +77,14 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool return p } +func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := Select(). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + return r.count(query, options...) +} + func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { query := Select(). LeftJoin("media_file f on f.id = media_file_id"). @@ -116,6 +124,30 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P return tracks, err } +func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId) + cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { + return t.PlaylistTrack + })), nil +} + +// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. +func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(options...).Columns("media_file_id"). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + var ids []string + if err := r.queryAllSlice(query, &ids); err != nil { + return nil, err + } + return ids, nil +} + func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go new file mode 100644 index 000000000..36f9ae4a9 --- /dev/null +++ b/persistence/playlist_track_repository_test.go @@ -0,0 +1,61 @@ +package persistence + +import ( + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PlaylistTrackRepository", func() { + var repo model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true) + }) + + Describe("GetCursor", func() { + It("yields the same tracks as GetAll", func() { + opts := model.QueryOptions{Sort: "id"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + + It("honors Max and Offset", func() { + opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(1)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + }) + + Describe("CountAll", func() { + It("returns the number of tracks in the playlist", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("ignores Max and Offset", func() { + Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) + }) + }) + + Describe("GetMediaFileIDs", func() { + It("returns the song ids in playlist order", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})). + To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) + }) + + It("honors Max and Offset", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). + To(Equal([]string{songRadioactivity.ID})) + }) + }) +}) diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 78b7938a1..46ad6a0de 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -67,7 +67,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec query = query. LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - " AND annotation.user_id = '"+userID+"')"). + " AND annotation.item_type = ?"+ + " AND annotation.user_id = ?)", r.tableName, userID). Columns( "coalesce(starred, 0) as starred", "coalesce(rating, 0) as rating", diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ce5221d19..d0cbb2946 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -347,6 +347,24 @@ func (r sqlRepository) queryOne(sq Sqlizer, response any) error { return err } +// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's +// embedded model, which a type parameter can't reach on its own. +func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + for row, err := range cursor { + m := toModel(row) + if m == nil { + var zero T + yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + return + } + if !yield(*m, err) || err != nil { + return + } + } + } +} + // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 88acebb7f..5177bc8e4 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,7 @@ func marshalTags(tags model.Tags) string { return string(res) } +// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "_id" key maps to "$.". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") return Exists( diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 63ea5cf60..21778506a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -6,7 +6,7 @@ "fields": { "albumArtist": "专辑艺人", "duration": "时长", - "trackNumber": "音轨号", + "trackNumber": "曲目序号", "playCount": "播放次数", "title": "标题", "artist": "艺人", @@ -22,6 +22,8 @@ "bitRate": "比特率", "bitDepth": "位深度", "sampleRate": "采样率", + "albumGain": "专辑增益", + "trackGain": "曲目增益", "channels": "声道", "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", @@ -142,7 +144,7 @@ "name": "用户", "fields": { "userName": "用户名", - "isAdmin": "是否管理员", + "isAdmin": "是否为管理员", "lastLoginAt": "上次登录", "lastAccessAt": "上次访问", "updatedAt": "更新于", @@ -623,11 +625,11 @@ "lastfmScrobbling": "启用 Last.fm 的个性化记录", "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", "replaygain": "回放增益", - "preAmp": "前置放大器 (dB)", + "preAmp": "回放增益 - 前置放大 (dB)", "gain": { - "none": "禁用增益", - "album": "使用专辑增益信息", - "track": "使用歌曲增益信息" + "none": "禁用", + "album": "使用专辑增益", + "track": "使用曲目增益" } } }, diff --git a/scanner/controller.go b/scanner/controller.go index 175b92e26..463718ba3 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "sync" "sync/atomic" "time" @@ -13,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -211,6 +214,16 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ ctx := request.AddValues(s.rootCtx, requestCtx) ctx = auth.WithAdminUser(ctx, s.ds) + // A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens + // inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must + // be read before the scan: ScanEnd clears the flag. + effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets) + if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Scanner: Error marking DB analysis pending", err) + } + } + // Send the initial scan status event s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) progress := make(chan *ProgressInfo, 100) @@ -229,6 +242,15 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ if scanError != nil { _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) } + // Refresh the query-planner statistics after a successful full scan. This must run in the + // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the + // server's pooled connections; their shared schema cache keeps the old statistics until the + // process restarts. + if effectiveFullScan && scanError == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Scanner: Error analyzing DB", err) + } + } // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") @@ -255,18 +277,73 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ // This is a global variable that is used to prevent multiple scans from running at the same time. // "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg -var running atomic.Bool +var ( + running atomic.Bool + scanMaintenanceMux sync.Mutex +) func lockScan(ctx context.Context) (func(), error) { if !running.CompareAndSwap(false, true) { log.Debug(ctx, "Scanner already running, ignoring request") return func() {}, ErrAlreadyScanning } + scanMaintenanceMux.Lock() return func() { + scanMaintenanceMux.Unlock() running.Store(false) }, nil } +// LockForMaintenance prevents a scan from starting while database maintenance is running. +func LockForMaintenance() (func(), bool) { + if !scanMaintenanceMux.TryLock() { + return func() {}, false + } + if running.Load() { + scanMaintenanceMux.Unlock() + return func() {}, false + } + return scanMaintenanceMux.Unlock, true +} + +// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted +// full scan in one of the included libraries. +func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { + if fullScan { + return true + } + return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { + return library.FullScanInProgress + }) +} + +func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool { + return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool { + return library.LastScanAt.IsZero() + }) +} + +// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is +// empty) matches pred. +func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { + libraries, err := ds.Library(ctx).GetAll() + if err != nil { + return false + } + if len(targets) == 0 { + return slices.ContainsFunc(libraries, pred) + } + + targeted := make(map[int]struct{}, len(targets)) + for _, target := range targets { + targeted[target.LibraryID] = struct{}{} + } + return slices.ContainsFunc(libraries, func(library model.Library) bool { + _, ok := targeted[library.ID] + return ok && pred(library) + }) +} + func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) { s.count.Store(0) s.folderCount.Store(0) diff --git a/scanner/controller_test.go b/scanner/controller_test.go index d60d432b4..e4814da64 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -55,3 +55,41 @@ var _ = Describe("Controller", func() { }) }) }) + +var _ = Describe("LockForMaintenance", func() { + It("allows only one database maintenance operation at a time", func() { + release, ok := scanner.LockForMaintenance() + Expect(ok).To(BeTrue()) + DeferCleanup(release) + + _, ok = scanner.LockForMaintenance() + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("EffectiveFullScan", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + libraries := &tests.MockLibraryRepo{} + libraries.SetData(model.Libraries{ + {ID: 1, FullScanInProgress: true}, + {ID: 2}, + }) + ds = &tests.MockDataStore{MockedLibrary: libraries} + }) + + It("detects an interrupted full scan in a targeted library", func() { + targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) + }) + + It("detects an interrupted full scan when scanning all libraries", func() { + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue()) + }) + + It("ignores interrupted full scans in untargeted libraries", func() { + targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) + }) +}) diff --git a/scanner/scanner.go b/scanner/scanner.go index 871b0c696..27e2b19d2 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -161,9 +160,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Update last_scan_completed_at for all libraries s.runUpdateLibraries(ctx, &state), - - // Optimize DB - s.runOptimize(ctx), ) if err != nil { log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) @@ -280,15 +276,6 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun } } -func (s *scannerImpl) runOptimize(ctx context.Context) func() error { - return func() error { - start := time.Now() - db.Optimize(ctx) - log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) - return nil - } -} - func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6c70eb268..17772bf9d 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -4,10 +4,12 @@ import ( "context" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" @@ -80,7 +82,7 @@ var _ = Describe("ScanFolders", Ordered, func() { rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) - createFS(fstest.MapFS{ + fsys = createFS(fstest.MapFS{ "rock/track1.mp3": rock(track(1, "Rock Track 1")), "rock/track2.mp3": rock(track(2, "Rock Track 2")), "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), @@ -122,6 +124,38 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + }) + + Describe("Planner statistics maintenance", func() { + It("does not mark routine quick-scan changes for immediate analysis", func() { + rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) + fsys = createFS(fstest.MapFS{ + "rock/track1.mp3": rock(track(1, "Rock Track 1")), + }) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + + fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("does not treat an interrupted scan in an untargeted library as a full scan", func() { + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed()) + + lastAnalyze := "2026-07-09T12:00:00Z" + Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) diff --git a/server/subsonic/filter/filters.go b/server/filter/filters.go similarity index 59% rename from server/subsonic/filter/filters.go rename to server/filter/filters.go index d19e163dd..e149dbced 100644 --- a/server/subsonic/filter/filters.go +++ b/server/filter/filters.go @@ -61,6 +61,19 @@ func AlbumsByArtistID(artistId string) Options { }) } +// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the +// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an +// artist's own discography never leaks into it. +func AlbumsByContributingArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "max_year", + Filters: And{ + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + }, + }) +} + func AlbumsByYear(fromYear, toYear int) Options { orderOption := "" if fromYear > toYear { @@ -90,6 +103,17 @@ func SongsByAlbum(albumId string) Options { }) } +// SongsByArtistID matches media files where the artist participates as album or track artist, in +// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale. +func SongsByArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "album", + Filters: Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))", + artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()), + }) +} + func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { options := Options{} ff := And{} @@ -138,6 +162,21 @@ func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { return opts } +// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist, +// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter). +func ArtistsByRole(opts Options, role model.Role) Options { + if _, ok := model.AllRoles[role.String()]; !ok { + return opts + } + roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL") + if opts.Filters == nil { + opts.Filters = roleFilter + } else { + opts.Filters = And{opts.Filters, roleFilter} + } + return opts +} + func ByGenre(genre string) Options { return addDefaultFilters(Options{ Sort: "name", @@ -145,11 +184,29 @@ func ByGenre(genre string) Options { }) } +// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids. +func ByGenreID(genreIds []string) Sqlizer { + return genreTagFilter(Eq{"value": genreIds}) +} + +// ArtistsByGenreID matches artists credited as album artist on an album with any of the given +// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. +func ArtistsByGenreID(genreIds []string) Sqlizer { + return Expr( + `artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt + WHERE jt.atom IS NOT NULL AND ?)`, + genreTagFilter(Eq{"value": genreIds}), + ) +} + +// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry +// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. +func genreTagFilter(cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +} + func filterByGenre(genre string) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{ - Like{"value": genre}, - NotEq{"atom": nil}, - }) + return genreTagFilter(Like{"value": genre}) } func ByRating() Options { diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md new file mode 100644 index 000000000..2e4c19950 --- /dev/null +++ b/server/jellyfin/README.md @@ -0,0 +1,352 @@ +# Jellyfin API + +This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of +Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets +Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), +[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +requiring a real Jellyfin server. + +It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music +library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report +playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's +admin/dashboard APIs are out of scope. + +## Enabling + +The Jellyfin API is disabled by default. Enable it via `navidrome.toml`: + +```toml +[Jellyfin] +Enabled = true +# Optional: override the server name reported to clients (defaults to "Navidrome ") +ServerName = "My Music Server" +# Optional: usernames to show in the client login user-picker (default: none). See "Public user list". +ExposedPublicUsers = "alice, bob" +``` + +or via environment variables: + +```bash +ND_JELLYFIN_ENABLED=true +ND_JELLYFIN_SERVERNAME="My Music Server" +ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +``` + +Once enabled, the API is mounted at: + +``` +http://:/jellyfin +``` + +All the paths below are relative to that base URL (e.g. `System/Info/Public` means +`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, +since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. + +## Authentication + +Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome +username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on +every subsequent request as the `X-Emby-Token` header (or embedded in the +`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey` +query param — all forms are accepted, matching what different clients do). + +`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native +`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force +surface. + +### Public user list (login picker) + +`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the +password) instead of a blank username field. It's **unauthenticated**, so by default it exposes +**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise: + +```toml +[Jellyfin] +ExposedPublicUsers = "alice, bob" +``` + +Only the named users are listed (never the full user table), resolved live per request; a configured +name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`) +with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no +avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images). + +## Players and sessions + +Every authenticated request registers (or refreshes) the calling device as a Navidrome player, +mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling +has a player) as soon as it makes any authenticated call, not only when it reports playback. The +player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is +`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them +(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no +client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is +skipped, so it doesn't create a nameless player. + +## ID encoding + +Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id +is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients +parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`, +which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id +from an old migrated library is itself valid hex, correctness depends on every emit path encoding +and every receive path decoding — see `dto/ids.go`. + +## Multi-library behavior + +Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each +Navidrome library the current user can access is exposed as its own top-level Jellyfin +"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. +Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the +authenticated user has access to; a library (or item within it) the user cannot access returns +`404`, never `403`, so ids can't be used as an existence oracle. + +### Browsing filters + +`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id +for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into +an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or +tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `GenreIds` (a +genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` +and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); +`SearchTerm`; +favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). + +## Implemented endpoints + +| Area | Endpoints | +|---|---| +| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | +| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | +| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | +| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` | +| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` | +| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | +| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | +| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` | +| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | +| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | +| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | + +Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level +as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint +that isn't in the table above, check the server logs for these lines to see exactly what it's +requesting. + +## Playlist management + +Playlists are the main writable surface of this API: + +- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`) + or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album, + artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before + the write, matching how Jellyfin clients populate these lists. A bare song id passes through. +- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients + spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a + single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one + track of an expanded album. +- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp + uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an + omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to + Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`. +- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body, + JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it. + Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, + so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're + bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins. +- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the + playlist-track row id, distinct from the song id) so a client can echo it back via + `DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more + than once in the same playlist. + +Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if +it is visible to them (public) or `404` if it is not (private) — the API never reveals that +someone else's private playlist exists. + +## Images + +The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive, +matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is +therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache +warmer uses — so user-scoped items like private playlists still resolve their cover instead of +falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to +their Navidrome `ArtworkID`. + +## Finamp saved-queue id truncation + +Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that +when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16 +bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into +GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for +those **truncated ids** back when restoring the queue — item lookups, then streaming, images, +favorites and playback reports for the restored tracks. + +This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome +id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; +ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as +requested**, because Finamp matches restored items back to its stored ids, and the other item +endpoints accept truncated ids transparently. + +**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`) +should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the +queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids, +so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer +can be removed. + +## Streaming and transcoding + +The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint: + +- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the + `.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`. + `audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true` + forces direct play (raw), never a transcode. +- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin. + Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g. + DSF) can't be rescued server-side on this path. +- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting + is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive + transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. + Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`, + `mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic + transcoded streams. +- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings → + Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as + Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are + advertised and served but packed-audio players won't decode them. + +## AudioMuse-AI compatible endpoints + +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no +external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` +plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. + +- `GET /AudioMuseAI/info` — returns `{"Version": , "AvailableEndpoints": [...]}` (200). + `AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty. +- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404. +- `GET /AudioMuseAI/similar_tracks?item_id=&n=10&eliminate_duplicates=true` — 404 when no provider is + loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match + or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist. +- `GET /AudioMuseAI/find_path?start_song_id=&end_song_id=&max_steps=25` — 404 when no provider is + loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": }` (200), or 400 + with `start_song_id and end_song_id are required.` when either id is missing. + +`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients. +`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features +(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library +setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries +the caller can access, since hops through inaccessible libraries are filtered out of the result. + +## curl walkthrough + +This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the +library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist. + +```bash +BASE=http://localhost:4533/jellyfin + +# 1. Handshake (no auth required) +curl -s "$BASE/System/Info/Public" | jq . + +# 2. Login - capture the AccessToken +TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \ + -H 'Content-Type: application/json' \ + -d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken) + +AUTH=(-H "X-Emby-Token: $TOKEN") + +# 3. List the user's views (one per accessible library) +curl -s "${AUTH[@]}" "$BASE/UserViews" | jq . + +# 4. Browse artists +curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq . +ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id') + +# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum) +ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id') + +# 6. List the album's songs +USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id) +SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \ + | jq -r '.Items[0].Id') + +# 7. Ask for playback info, then stream the song +curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq . +curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio + +# 8. Favorite the song +curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq . + +# 9. Report playback start/stop (also drives scrobbling) +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing" +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped" + +# 10. Create a playlist from a whole album (the album id is expanded to its tracks) +PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id) + +# 11. Make it public, then remove one entry +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID" +ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId') +curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID" + +# 12. Delete the playlist +curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" +``` + +## Testing + +Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in +[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and +real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot +isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with: + +```bash +make test PKG=./server/jellyfin/... +``` + +## Known limitations + +- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's + libraries (genre tags aren't per-library entities in Navidrome's model). +- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each + belong to exactly one library and are checked against `user.HasLibraryAccess` on every + fetch), an artist can have content across multiple libraries via `library_artist`, so there's + no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against. + Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` + *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client + that already has an artist id from elsewhere is not re-checked against library membership. +- **MD5-hash ids from old migrated libraries.** The hex id codec assumes ids are opaque; a raw + 32-char MD5 id is itself valid hex and so must be encoded/decoded symmetrically like any other. + This is handled, but is the most fragile id case — see the note in `dto/ids.go`. +- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is + populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** + blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a + multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and + stores it per image, so its placeholder approximates the art. Ours satisfies the protocol + (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash + warning) but renders as a flat color while art loads. A proper implementation would compute the + real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it + keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback + for art that hasn't been rendered yet. +- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a + `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a + working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up + would broadcast real session/playstate and library-change events over it (via `server/events`), + mirroring Jellyfin's session messages. +- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp + and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve + Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both + clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level + timing, so word-sync is out of scope.) +- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are + backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed + track followed by the provider's similar songs (with agents disabled it degrades to a seed-only + mix). A follow-up would back them with Navidrome's `core/sonic` provider — the same one behind + the OpenSubsonic `sonicSimilarity` extension (`getSonicSimilarTracks`) that AudioMuse-AI feeds + via its Navidrome plugin, and the exact endpoint AudioMuse's own Jellyfin plugin overrides. + Needs the `core/sonic.Sonic` service injected into the `Router` (wire change). diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go new file mode 100644 index 000000000..8f900ee87 --- /dev/null +++ b/server/jellyfin/annotations.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "errors" + "math" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't +// access their library; artists span libraries (library_artist), so have no single LibraryID to +// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. +// When ok is false the response has already been written, so callers must return without writing +// the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.Album(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if _, err := api.ds.Artist(ctx).Get(id); err == nil { + return api.ds.Artist(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.MediaFile(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + playlistRepo := api.ds.Playlist(ctx) + if _, err := playlistRepo.Get(id); err == nil { + return playlistRepo, true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false +} + +// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify +// fetches this per item to render played/favourite indicators; resolveItemByID enforces the +// library-access gate. +func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + item, ok := api.resolveItemByID(r.Context(), id, nil) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + data := item.UserData + if data == nil { + // Items without annotations still return a valid empty UserData. + data = dto.UserData(model.Annotations{}, id) + } + api.ok(w, r, data) +} + +func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetStar(starred, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) +} + +func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } +func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { + api.setFavorite(w, r, false) +} + +func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetRating(rating, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} + if rating > 0 { + jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData + d.Rating = &jfRating + } + api.ok(w, r, d) +} + +// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to +// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since +// SetRating(0) is the delete path. +func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { + jfRating := req.Params(r).Float64Or("rating", 0) + jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating + rating := int(math.Round(jfRating / 2)) + if jfRating > 0 { + rating = max(rating, 1) + } + api.setItemRating(w, r, rating) +} + +func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { + api.setItemRating(w, r, 0) +} diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go new file mode 100644 index 000000000..4a7efabd7 --- /dev/null +++ b/server/jellyfin/annotations_test.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + var api *Router + var ds *tests.MockDataStore + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("markFavorite / unmarkFavorite", func() { + It("stars a song and returns IsFavorite=true", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(mfRepo.Data["s1"].Starred).To(BeTrue()) + }) + + It("stars an album and returns IsFavorite=true", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(albumRepo.Data["a1"].Starred).To(BeTrue()) + }) + + It("stars an artist without checking library access (artists span multiple libraries)", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + // alice only has access to library 1, but artists aren't gated per-library. + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "ar1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(artistRepo.Data["ar1"].Starred).To(BeTrue()) + }) + + It("stars a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Starred["p1"]).To(BeTrue()) + }) + + It("unstars a song and returns IsFavorite=false", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.unmarkFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeFalse()) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star a song in a library the user can't access", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "x1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("setRating / removeRating", func() { + It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).NotTo(BeNil()) + Expect(*d.Rating).To(Equal(8.0)) + }) + + It("rates an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(5)) + }) + + It("rates a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Ratings["p1"]).To(Equal(4)) + }) + + It("removes a rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.removeRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).To(BeNil()) + }) + + It("returns 404 and does not rate an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(0)) + }) + + It("rounds an odd rating to the nearest star instead of truncating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("stores the minimum star for Rating=1 instead of clearing the rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(1)) + }) + + It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + }) + + It("clamps a Rating above 10 to Navidrome's max (5)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("clamps a negative Rating to Navidrome's min (0)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go new file mode 100644 index 000000000..0740901aa --- /dev/null +++ b/server/jellyfin/api.go @@ -0,0 +1,223 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "golang.org/x/sync/singleflight" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "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/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type Router struct { + http.Handler + ds model.DataStore + artwork artwork.Artwork + streamer stream.MediaStreamer + transcodeDecider stream.TranscodeDecider + players core.Players + scrobbler scrobbler.PlayTracker + playlists playlists.Playlists + provider external.Provider + sonic sonic.Engine + similarFlight singleflight.Group + serverIDMu sync.Mutex + serverIDVal string +} + +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, + transcodeDecider stream.TranscodeDecider, players core.Players, + scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, + sonicSvc sonic.Engine) *Router { + r := &Router{ + ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, + players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + sonic: sonicSvc, + } + r.Handler = r.routes() + return r +} + +func (api *Router) routes() http.Handler { + inner := chi.NewRouter() + + // Read query params case-insensitively, like real Jellyfin. Must precede all routes so every + // handler and the api_key check see folded keys. + inner.Use(normalizeQueryKeys) + + // Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase. + + // Public (no auth): handshake + login. + inner.Get("/system/info/public", api.getPublicSystemInfo) + inner.Get("/system/ping", api.ping) + inner.Post("/system/ping", api.ping) + inner.Get("/quickconnect/enabled", api.quickConnectEnabled) + // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated + // brute-force surface, so it must share the same per-IP throttle when one is configured. + if conf.Server.AuthRequestLimit > 0 { + limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) + } else { + inner.Post("/users/authenticatebyname", api.authenticateByName) + } + inner.Get("/users/public", api.getPublicUsers) + + // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. + // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, + // and an unbounded burst (a client fetching artwork across a large library) can exhaust memory. + inner.Group(func(r chi.Router) { + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) + r.Get("/items/{itemId}/images/{type}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) + }) + + inner.Group(func(r chi.Router) { + r.Use(api.authenticate) + // Register/refresh the calling device as a player on every authenticated request, like + // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a + // player) even before the first playback report. + r.Use(api.withPlayer) + r.Get("/userviews", api.getUserViews) + r.Get("/users/{userId}/views", api.getUserViews) + r.Get("/users/me", api.getCurrentUser) + r.Get("/users/{userId}", api.getCurrentUser) + + // Cursor-backed collections: each streams straight from the DB, holding a connection for the + // whole client-paced response, so enough slow clients would take the entire pool and stall the + // scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess + // requests queue rather than fail. + r.Group(func(r chi.Router) { + r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) + r.Get("/items", api.getItems) + r.Get("/users/{userId}/items", api.getItems) + r.Get("/users/{userId}/items/latest", api.getLatest) + r.Get("/artists", api.getArtists) + r.Get("/artists/albumartists", api.getAlbumArtists) + r.Get("/playlists/{playlistId}/items", api.getPlaylistItems) + }) + + r.Get("/items/{itemId}", api.getItem) + r.Get("/users/{userId}/items/{itemId}", api.getItem) + r.Delete("/items/{itemId}", api.deleteItem) + + // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the + // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. + r.Post("/userfavoriteitems/{itemId}", api.markFavorite) + r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) + r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) + r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) + + // Per-item play/favorite/rating state. Jellify uses the /UserItems form; + // /Users/{userId}/Items is the legacy spelling. + r.Get("/useritems/{itemId}/userdata", api.getUserItemData) + r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) + + r.Get("/artists/{itemId}/similar", api.getSimilarArtists) + r.Get("/items/{itemId}/similar", api.getSimilarItems) + r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/genres", api.getGenres) + r.Get("/musicgenres", api.getGenres) + + r.Post("/playlists", api.createPlaylist) + r.Get("/playlists/{playlistId}", api.getPlaylist) + r.Post("/playlists/{playlistId}", api.updatePlaylist) + r.Post("/playlists/{playlistId}/items", api.addToPlaylist) + r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) + r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) + + // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes + // above stay public. + r.Post("/items/{itemId}/images/{type}", api.postItemImage) + r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage) + + r.Get("/audio/{itemId}/stream", api.streamAudio) + r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of + // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. + r.Get("/items/{itemId}/file", api.streamFile) + r.Get("/items/{itemId}/download", api.streamFile) + + r.Post("/sessions/playing", api.reportPlaybackStart) + r.Post("/sessions/playing/progress", api.reportPlaybackProgress) + r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) + r.Post("/sessions/capabilities", api.postCapabilities) + r.Post("/sessions/capabilities/full", api.postCapabilities) + + // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. + r.Get("/socket", api.handleSocket) + + r.Get("/audiomuseai/info", api.audioMuseInfo) + r.Get("/audiomuseai/health", api.audioMuseHealth) + r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks) + r.Get("/audiomuseai/find_path", api.audioMuseFindPath) + }) + + // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected + // traffic, and this just surfaces what's missing. + inner.NotFound(api.notFound) + inner.MethodNotAllowed(api.notFound) + + // Real Jellyfin clients route case-insensitively; chi does not. + return caseInsensitivePaths(inner) +} + +// ok writes payload as JSON — the single entry point for every handler. Collections are routed to +// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped +// on any item(s): real Jellyfin always sets it, and it's constant per request. +// +// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray). +func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { + switch p := payload.(type) { + case itemsResult: + api.writeItems(w, r, p) + return + case dto.QueryResult: + api.writeItems(w, r, materialized(p)) + return + case dto.BaseItemDto: + p.ServerId = api.serverID(r.Context()) + payload = p + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Error(r.Context(), "Jellyfin API: error encoding response", err) + } +} + +// notFound handles unmatched routes and unsupported methods, logging them so unimplemented +// endpoints surface instead of returning chi's default plain-text 404/405. +func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { + log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{}`)) +} + +// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output, +// file paths) never reaches the client. +func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) { + log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) +} diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go new file mode 100644 index 000000000..1b390e75a --- /dev/null +++ b/server/jellyfin/api_test.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Router", func() { + It("serves the public handshake through the mounted handler", func() { + ds := &tests.MockDataStore{} + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 JSON for unknown routes", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("returns 404 JSON for a known path with an unsupported method", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("registers a player on a general authenticated request, not just playback reports", func() { + ds := &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + fp := &fakePlayers{} + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`) + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fp.registerCalls).To(Equal(1)) + Expect(fp.lastClient).To(Equal("Jellify")) + }) + + It("rate-limits AuthenticateByName by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + + login := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + api.ServeHTTP(w, r) + return w.Code + } + // The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429. + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusTooManyRequests)) + }) +}) diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go new file mode 100644 index 000000000..b01a4bf92 --- /dev/null +++ b/server/jellyfin/audiomuse.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin, +// and is sorted the same way (the plugin builds it with OrderBy). +var audioMuseEndpoints = []string{ + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", +} + +type audioMuseInfoResponse struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` +} + +func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) { + endpoints := []string{} // non-nil so an empty list serializes as [], not null + if api.sonic != nil && api.sonic.HasProvider() { + endpoints = audioMuseEndpoints + } + api.ok(w, r, audioMuseInfoResponse{ + Version: consts.Version, + AvailableEndpoints: endpoints, + }) +} + +// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else +// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable. +func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) { + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + w.WriteHeader(http.StatusOK) +} + +type audioMuseSimilarTrack struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` +} + +func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // 404 without a provider, like the Subsonic sonicSimilarity handlers. + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + tracks := []audioMuseSimilarTrack{} + + itemID := p.StringOr("item_id", "") + if itemID == "" { + api.ok(w, r, tracks) + return + } + + id := api.resolveItemID(ctx, dto.DecodeID(itemID)) + n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit + eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n) + if err != nil { + api.ok(w, r, tracks) + return + } + + u, _ := request.UserFrom(ctx) + seenArtists := make(map[string]bool, len(matches)) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + if eliminateDuplicates { + key := strings.ToLower(mf.Artist) + if seenArtists[key] { + continue + } + seenArtists[key] = true + } + tracks = append(tracks, audioMuseSimilarTrack{ + Author: mf.Artist, + Distance: m.Similarity, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + }) + } + api.ok(w, r, tracks) +} + +type audioMusePathTrack struct { + Author string `json:"author"` + ItemID string `json:"item_id"` + Title string `json:"title"` + Tempo *float64 `json:"tempo,omitempty"` +} + +type audioMusePathResponse struct { + Path []audioMusePathTrack `json:"path"` + TotalDistance float64 `json:"total_distance"` +} + +func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + + startID := p.StringOr("start_song_id", "") + endID := p.StringOr("end_song_id", "") + if startID == "" || endID == "" { + http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest) + return + } + + resp := audioMusePathResponse{Path: []audioMusePathTrack{}} + maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count + matches, err := api.sonic.FindSonicPath(ctx, + api.resolveItemID(ctx, dto.DecodeID(startID)), + api.resolveItemID(ctx, dto.DecodeID(endID)), + maxSteps) + if err != nil { + api.ok(w, r, resp) + return + } + + u, _ := request.UserFrom(ctx) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + track := audioMusePathTrack{ + Author: mf.Artist, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + } + if mf.BPM != nil { + tempo := float64(*mf.BPM) + track.Tempo = &tempo + } + resp.Path = append(resp.Path, track) + resp.TotalDistance += m.Similarity + } + api.ok(w, r, resp) +} diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go new file mode 100644 index 000000000..e9d6d4e85 --- /dev/null +++ b/server/jellyfin/audiomuse_test.go @@ -0,0 +1,250 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse info", func() { + It("lists the sonic endpoints (excluding info) when a provider is present", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("returns an empty endpoint list when no provider is loaded", func() { + api := &Router{} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.AvailableEndpoints).To(BeEmpty()) + Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`)) + }) +}) + +type fakeSonicEngine struct { + provider bool + similar []sonic.SimilarMatch + similarErr error + path []sonic.SimilarMatch + pathErr error + gotID string + gotStart string + gotEnd string + gotCount int +} + +func (f *fakeSonicEngine) HasProvider() bool { return f.provider } + +func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) { + f.gotID, f.gotCount = id, count + return f.similar, f.similarErr +} + +func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) { + f.gotStart, f.gotEnd, f.gotCount = startID, endID, count + return f.path, f.pathErr +} + +func mf(id, artist, title string, lib int) model.MediaFile { + return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib} +} + +var _ = Describe("AudioMuse health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("returns 404 when no provider is loaded", func() { + api := &Router{} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) +}) + +// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request. +func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", path+"?"+query, nil) + r = r.WithContext(request.WithUser(r.Context(), user)) + invoke(handler, w, r) + return w +} + +var _ = Describe("AudioMuse similar_tracks", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotID).To(Equal("seed")) + Expect(fake.gotCount).To(Equal(5)) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + Expect(body[0]).To(Equal(audioMuseSimilarTrack{ + Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1", + })) + }) + + It("collapses to one track per artist when eliminate_duplicates defaults on", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(1)) + }) + + It("keeps same-artist tracks when eliminate_duplicates=false", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + }) + + It("filters out tracks in libraries the user cannot access", func() { + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}}) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("returns an empty array without calling the engine when item_id is missing", func() { + w := call("n=5", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + Expect(fake.gotID).To(Equal("")) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty array when the engine errors", func() { + fake.similarErr = errors.New("boom") + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) +}) + +var _ = Describe("AudioMuse find_path", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("returns 400 with the exact message when start_song_id is missing", func() { + w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns 400 when end_song_id is missing", func() { + w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + }) + + It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { + bpm := 120 + withBPM := mf("mf1", "A", "T1", 1) + withBPM.BPM = &bpm + fake.path = []sonic.SimilarMatch{ + {MediaFile: withBPM, Similarity: 1.5}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0}, + } + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotStart).To(Equal("s")) + Expect(fake.gotEnd).To(Equal("e")) + Expect(fake.gotCount).To(Equal(10)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1"))) + Expect(*body.Path[0].Tempo).To(Equal(120.0)) + Expect(body.Path[1].Tempo).To(BeNil()) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty path object when the engine errors", func() { + fake.pathErr = errors.New("boom") + fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}} + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(BeEmpty()) + Expect(body.TotalDistance).To(Equal(0.0)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go new file mode 100644 index 000000000..062ac6458 --- /dev/null +++ b/server/jellyfin/auth.go @@ -0,0 +1,134 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type authenticateByNameRequest struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body authenticateByNameRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. + usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) + if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { + log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a + // never/stale "Last Login" in the admin UI. + if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { + log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) + } + + token, err := auth.CreateToken(usr) + if err != nil { + api.internalError(w, r, err) + return + } + + // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's + // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + api.ok(w, r, dto.AuthenticationResult{ + User: userToDto(usr, api.serverName(), api.serverID(ctx)), + AccessToken: token, + ServerId: api.serverID(ctx), + }) +} + +// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after +// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults. +func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { + return &dto.UserDto{ + Name: u.UserName, + Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid + ServerId: serverID, + ServerName: serverName, + HasPassword: true, + HasConfiguredPassword: true, + Policy: userPolicy(u), + Configuration: userConfiguration(), + } +} + +func userPolicy(u *model.User) *dto.UserPolicy { + return &dto.UserPolicy{ + IsAdministrator: u.IsAdmin, + IsHidden: false, + EnableCollectionManagement: false, + EnableSubtitleManagement: false, + EnableLyricManagement: false, + IsDisabled: false, + BlockedTags: []string{}, + AllowedTags: []string{}, + EnableUserPreferenceAccess: true, + AccessSchedules: []string{}, + BlockUnratedItems: []string{}, + EnableRemoteControlOfOtherUsers: false, + EnableSharedDeviceControl: false, + EnableRemoteAccess: true, + EnableLiveTvManagement: false, + EnableLiveTvAccess: false, + EnableMediaPlayback: true, + EnableAudioPlaybackTranscoding: true, + EnableVideoPlaybackTranscoding: true, + EnablePlaybackRemuxing: true, + ForceRemoteSourceTranscoding: false, + EnableContentDeletion: false, + EnableContentDeletionFromFolders: []string{}, + EnableContentDownloading: true, + EnableSyncTranscoding: true, + EnableMediaConversion: true, + EnabledDevices: []string{}, + EnableAllDevices: true, + EnabledChannels: []string{}, + EnableAllChannels: false, + EnabledFolders: []string{}, + EnableAllFolders: true, + InvalidLoginAttemptCount: 0, + LoginAttemptsBeforeLockout: -1, + MaxActiveSessions: 0, + EnablePublicSharing: true, + BlockedMediaFolders: []string{}, + BlockedChannels: []string{}, + RemoteClientBitrateLimit: 0, + AuthenticationProviderId: "", + PasswordResetProviderId: "", + SyncPlayAccess: "CreateAndJoinGroups", + } +} + +func userConfiguration() *dto.UserConfiguration { + return &dto.UserConfiguration{ + PlayDefaultAudioTrack: true, + SubtitleLanguagePreference: "", + DisplayMissingEpisodes: false, + GroupedFolders: []string{}, + SubtitleMode: "Default", + DisplayCollectionsView: false, + EnableLocalPassword: false, + OrderedViews: []string{}, + LatestItemsExcludes: []string{}, + MyMediaExcludes: []string{}, + HidePlayedInLatest: true, + RememberAudioSelections: true, + RememberSubtitleSelections: true, + EnableNextEpisodeAutoPlay: true, + CastReceiverId: "", + } +} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go new file mode 100644 index 000000000..b51420f1a --- /dev/null +++ b/server/jellyfin/auth_test.go @@ -0,0 +1,103 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AuthenticateByName", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + It("issues a token for valid credentials", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User.Name).To(Equal("alice")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + // Finamp reads Policy/Configuration right after login and null-crashes if they're absent. + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) + Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) + Expect(res.User.Configuration).ToNot(BeNil()) + + // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's + // login doesn't require it, so it should be omitted entirely rather than sent partial. + Expect(res.SessionInfo).To(BeNil()) + }) + + It("records the login time, like the web UI login does", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr, err := ur.FindByUsername("alice") + Expect(err).ToNot(HaveOccurred()) + Expect(usr.LastLoginAt).ToNot(BeNil()) + }) + + It("reflects an administrator in the User.Policy", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"root","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + }) + + It("rejects invalid credentials with 401", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password even for a user with an empty stored password with 401", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"empty","Pw":""}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go new file mode 100644 index 000000000..fae21fc0b --- /dev/null +++ b/server/jellyfin/browsing.go @@ -0,0 +1,61 @@ +package jellyfin + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists +// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers +// don't appear identically in both. +func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleArtist) +} + +func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleAlbumArtist) +} + +// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library +// when accessible (like queryItems) or all accessible libraries otherwise. +func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) + + scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. + // Finamp's artist tab sends GenreIds when a genre filter is active. + q := itemsQuery{ + scopeIDs: scopeIDs, + genreIds: decodedQueryIDs(r, "genreids"), + search: searchTerm(p), + } + if q.search != "" { + opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) + } + + res, err := api.listArtists(ctx, opts, q, role) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies. +func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + res, err := api.listGenres(ctx, opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go new file mode 100644 index 000000000..660e5d293 --- /dev/null +++ b/server/jellyfin/browsing_test.go @@ -0,0 +1,178 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Browsing", func() { + var api *Router + var ds *tests.MockDataStore + ctxUser := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("getArtists", func() { + It("lists artists via /Artists", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("handles /Artists/AlbumArtists the same way", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("scopes results to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes to a single library when ParentId is an accessible library id", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(2)) + Expect(args).NotTo(ContainElement(1)) + }) + + It("does not let ParentId= narrow the scope", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("bounds a search the client left unbounded, and clamps an oversized one", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Offset).To(Equal(5)) + Expect(artistRepo.Options.Max).To(Equal(10)) + }) + + It("does not restrict results for an admin user", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added. + if artistRepo.Options.Filters == nil { + return + } + sql, _, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) + }) + }) + + Describe("getGenres", func() { + It("lists genres via /Genres", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("handles /MusicGenres the same way", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) +}) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go new file mode 100644 index 000000000..aaf6ff2af --- /dev/null +++ b/server/jellyfin/dto/blurhash.go @@ -0,0 +1,36 @@ +package dto + +import "hash/fnv" + +// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. +const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// base83 encodes value as a fixed-width, big-endian base83 string of the given length. +func base83(value, length int) string { + b := make([]byte, length) + for i := 1; i <= length; i++ { + digit := (value / pow83(length-i)) % 83 + b[i-1] = base83Alphabet[digit] + } + return string(b) +} + +func pow83(n int) int { + result := 1 + for range n { + result *= 83 + } + return result +} + +// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a +// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so +// a solid color unique to the tag satisfies both without decoding cover art. +func blurHash(seed string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + sum := h.Sum(nil) + r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) + dc := (r << 16) | (g << 8) | b + return "00" + base83(dc, 4) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go new file mode 100644 index 000000000..a6e36131d --- /dev/null +++ b/server/jellyfin/dto/blurhash_test.go @@ -0,0 +1,27 @@ +package dto + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("blurHash", func() { + It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { + h := blurHash("x") + Expect(h).To(HaveLen(6)) + Expect(h).To(HavePrefix("00")) + for _, c := range h { + Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("is deterministic for the same seed", func() { + Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) + }) + + It("differs for different seeds", func() { + Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) + }) +}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go new file mode 100644 index 000000000..7720640c5 --- /dev/null +++ b/server/jellyfin/dto/dto.go @@ -0,0 +1,258 @@ +package dto + +// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public). +type PublicSystemInfo struct { + LocalAddress string `json:"LocalAddress,omitempty"` + ServerName string `json:"ServerName"` + Version string `json:"Version"` + ProductName string `json:"ProductName"` + OperatingSystem string `json:"OperatingSystem,omitempty"` + Id string `json:"Id"` + StartupWizardCompleted bool `json:"StartupWizardCompleted"` +} + +// SystemInfo is the authenticated variant (GET /System/Info). +type SystemInfo struct { + PublicSystemInfo + HasPendingRestart bool `json:"HasPendingRestart"` + IsShuttingDown bool `json:"IsShuttingDown"` + SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"` + CachePath string `json:"CachePath,omitempty"` +} + +type NameGuidPair struct { + Name string `json:"Name"` + Id string `json:"Id"` +} + +type UserItemDataDto struct { + Rating *float64 `json:"Rating,omitempty"` + PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` + PlayCount int `json:"PlayCount"` + IsFavorite bool `json:"IsFavorite"` + Played bool `json:"Played"` + Key string `json:"Key"` + ItemId string `json:"ItemId,omitempty"` + LastPlayedDate *string `json:"LastPlayedDate,omitempty"` +} + +type BaseItemDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + Id string `json:"Id"` + // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), + // distinct from Id so a song appearing more than once can be removed by occurrence + // (DELETE .../Items?EntryIds=...) rather than by song id. + PlaylistItemId string `json:"PlaylistItemId,omitempty"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + LocationType string `json:"LocationType,omitempty"` + HasLyrics bool `json:"HasLyrics,omitempty"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + ParentId string `json:"ParentId,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + IndexNumber *int `json:"IndexNumber,omitempty"` + ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` + ProductionYear *int `json:"ProductionYear,omitempty"` + // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. + PremiereDate *string `json:"PremiereDate,omitempty"` + // DateCreated is the ISO 8601 date the item was added to the library; clients show it as + // "Date Added" and sort "Recently Added" by it. + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` + // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a + // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't + // calculating blurhashes. + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` +} + +// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which +// Finamp probes before allowing playlist edits. +type PlaylistUserPermissions struct { + UserId string `json:"UserId"` + CanEdit bool `json:"CanEdit"` +} + +// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not +// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle. +type PlaylistInfo struct { + OpenAccess bool `json:"OpenAccess"` + Shares []PlaylistUserPermissions `json:"Shares"` + ItemIds []string `json:"ItemIds"` +} + +type QueryResult struct { + Items []BaseItemDto `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` + StartIndex int `json:"StartIndex"` +} + +type UserDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + ServerName string `json:"ServerName,omitempty"` + Id string `json:"Id"` + HasPassword bool `json:"HasPassword"` + HasConfiguredPassword bool `json:"HasConfiguredPassword"` + HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"` + PrimaryImageTag string `json:"PrimaryImageTag,omitempty"` + Policy *UserPolicy `json:"Policy,omitempty"` + Configuration *UserConfiguration `json:"Configuration,omitempty"` +} + +// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if +// it's absent, so every field must be present even though Navidrome lacks most of these concepts. +type UserPolicy struct { + IsAdministrator bool `json:"IsAdministrator"` + IsHidden bool `json:"IsHidden"` + EnableCollectionManagement bool `json:"EnableCollectionManagement"` + EnableSubtitleManagement bool `json:"EnableSubtitleManagement"` + EnableLyricManagement bool `json:"EnableLyricManagement"` + IsDisabled bool `json:"IsDisabled"` + BlockedTags []string `json:"BlockedTags"` + AllowedTags []string `json:"AllowedTags"` + EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"` + AccessSchedules []string `json:"AccessSchedules"` + BlockUnratedItems []string `json:"BlockUnratedItems"` + EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"` + EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"` + EnableRemoteAccess bool `json:"EnableRemoteAccess"` + EnableLiveTvManagement bool `json:"EnableLiveTvManagement"` + EnableLiveTvAccess bool `json:"EnableLiveTvAccess"` + EnableMediaPlayback bool `json:"EnableMediaPlayback"` + EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"` + EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"` + EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"` + ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"` + EnableContentDeletion bool `json:"EnableContentDeletion"` + EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"` + EnableContentDownloading bool `json:"EnableContentDownloading"` + EnableSyncTranscoding bool `json:"EnableSyncTranscoding"` + EnableMediaConversion bool `json:"EnableMediaConversion"` + EnabledDevices []string `json:"EnabledDevices"` + EnableAllDevices bool `json:"EnableAllDevices"` + EnabledChannels []string `json:"EnabledChannels"` + EnableAllChannels bool `json:"EnableAllChannels"` + EnabledFolders []string `json:"EnabledFolders"` + EnableAllFolders bool `json:"EnableAllFolders"` + InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"` + LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"` + MaxActiveSessions int `json:"MaxActiveSessions"` + EnablePublicSharing bool `json:"EnablePublicSharing"` + BlockedMediaFolders []string `json:"BlockedMediaFolders"` + BlockedChannels []string `json:"BlockedChannels"` + RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"` + AuthenticationProviderId string `json:"AuthenticationProviderId"` + PasswordResetProviderId string `json:"PasswordResetProviderId"` + SyncPlayAccess string `json:"SyncPlayAccess"` +} + +// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it +// always present, even though most settings don't apply to Navidrome's audio-only library. +type UserConfiguration struct { + PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"` + SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"` + DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"` + GroupedFolders []string `json:"GroupedFolders"` + SubtitleMode string `json:"SubtitleMode"` + DisplayCollectionsView bool `json:"DisplayCollectionsView"` + EnableLocalPassword bool `json:"EnableLocalPassword"` + OrderedViews []string `json:"OrderedViews"` + LatestItemsExcludes []string `json:"LatestItemsExcludes"` + MyMediaExcludes []string `json:"MyMediaExcludes"` + HidePlayedInLatest bool `json:"HidePlayedInLatest"` + RememberAudioSelections bool `json:"RememberAudioSelections"` + RememberSubtitleSelections bool `json:"RememberSubtitleSelections"` + EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` + CastReceiverId string `json:"CastReceiverId"` +} + +type SessionInfo struct { + Id string `json:"Id"` + UserId string `json:"UserId"` +} + +type AuthenticationResult struct { + User *UserDto `json:"User"` + SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + AccessToken string `json:"AccessToken"` + ServerId string `json:"ServerId"` +} + +// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so +// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type +// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws. +type MediaStream struct { + Codec string `json:"Codec,omitempty"` + Type string `json:"Type"` + Index int `json:"Index"` + BitRate int `json:"BitRate,omitempty"` + Channels int `json:"Channels,omitempty"` + SampleRate int `json:"SampleRate,omitempty"` + ChannelLayout string `json:"ChannelLayout,omitempty"` + IsInterlaced bool `json:"IsInterlaced"` + IsDefault bool `json:"IsDefault"` + IsForced bool `json:"IsForced"` + IsExternal bool `json:"IsExternal"` + IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` + SupportsExternalStream bool `json:"SupportsExternalStream"` +} + +// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as +// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing +// of the whole item list; emit them always (no omitempty on bools). +type MediaSourceInfo struct { + Id string `json:"Id"` + Path string `json:"Path,omitempty"` + Protocol string `json:"Protocol"` + Container string `json:"Container,omitempty"` + TranscodingUrl string `json:"TranscodingUrl,omitempty"` + TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"` + Size int64 `json:"Size,omitempty"` + Name string `json:"Name,omitempty"` + IsRemote bool `json:"IsRemote"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + Bitrate int `json:"Bitrate,omitempty"` + SupportsTranscoding bool `json:"SupportsTranscoding"` + SupportsDirectStream bool `json:"SupportsDirectStream"` + SupportsDirectPlay bool `json:"SupportsDirectPlay"` + Type string `json:"Type"` + ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"` + IgnoreDts bool `json:"IgnoreDts"` + IgnoreIndex bool `json:"IgnoreIndex"` + GenPtsInput bool `json:"GenPtsInput"` + IsInfiniteStream bool `json:"IsInfiniteStream"` + UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"` + RequiresOpening bool `json:"RequiresOpening"` + RequiresClosing bool `json:"RequiresClosing"` + RequiresLooping bool `json:"RequiresLooping"` + SupportsProbing bool `json:"SupportsProbing"` + HasSegments bool `json:"HasSegments"` + MediaStreams []MediaStream `json:"MediaStreams"` + MediaAttachments []any `json:"MediaAttachments"` + Formats []string `json:"Formats"` +} + +type PlaybackInfoResponse struct { + MediaSources []MediaSourceInfo `json:"MediaSources"` + PlaySessionId string `json:"PlaySessionId"` +} diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go new file mode 100644 index 000000000..1d8ec47e4 --- /dev/null +++ b/server/jellyfin/dto/dto_suite_test.go @@ -0,0 +1,17 @@ +package dto + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDto(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin DTO Suite") +} diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go new file mode 100644 index 000000000..faa31ec0d --- /dev/null +++ b/server/jellyfin/dto/fields.go @@ -0,0 +1,24 @@ +package dto + +import "strings" + +// Fields is the parsed set of a Jellyfin request's Fields param (lowercased). It controls which +// conditional fields a mapped item carries — chiefly MediaSources — matching real Jellyfin, which +// omits those unless the client asks for them. +type Fields map[string]struct{} + +// ParseFields splits the comma-separated Fields param into a lowercased set. +func ParseFields(csv string) Fields { + f := Fields{} + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } + } + return f +} + +func (f Fields) Has(name string) bool { + _, ok := f[strings.ToLower(name)] + return ok +} diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go new file mode 100644 index 000000000..3490ba260 --- /dev/null +++ b/server/jellyfin/dto/ids.go @@ -0,0 +1,23 @@ +package dto + +import "encoding/hex" + +// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g. +// Finamp's queue packing) and crash on Navidrome's base62 nanoids if emitted as-is. +func EncodeID(id string) string { + if id == "" { + return "" + } + return hex.EncodeToString([]byte(id)) +} + +// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id. +func DecodeID(id string) string { + if id == "" { + return "" + } + if b, err := hex.DecodeString(id); err == nil && len(b) > 0 { + return string(b) + } + return id +} diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go new file mode 100644 index 000000000..26a957604 --- /dev/null +++ b/server/jellyfin/dto/ids_test.go @@ -0,0 +1,35 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("id codec", func() { + It("round-trips a base62 nanoid through Encode/Decode", func() { + id := "5QFKvMsJrd57QE2Le2dKKo" + Expect(DecodeID(EncodeID(id))).To(Equal(id)) + }) + + It("passes a raw (non-hex) id through DecodeID unchanged", func() { + Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo")) + }) + + It("produces valid lowercase hex", func() { + encoded := EncodeID("song-1") + Expect(encoded).To(MatchRegexp("^[0-9a-f]+$")) + Expect(encoded).To(HaveLen(len("song-1") * 2)) + }) + + It("round-trips the empty string", func() { + Expect(EncodeID("")).To(Equal("")) + Expect(DecodeID("")).To(Equal("")) + }) + + It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() { + // "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex + // string apart from one we encoded. Callers must always encode ids on emission and + // decode them on receipt so this ambiguity never surfaces in practice. + Expect(DecodeID(EncodeID("a1"))).To(Equal("a1")) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go new file mode 100644 index 000000000..bd817e432 --- /dev/null +++ b/server/jellyfin/dto/mappers.go @@ -0,0 +1,256 @@ +package dto + +import ( + "cmp" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" +) + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } + +// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 +// PremiereDate clients parse, falling back to year; nil when neither exists. +func premiereDate(date string, year int) *string { + d := date + switch len(d) { + case 4: + d += "-01-01" + case 7: + d += "-01" + case 10: // already yyyy-mm-dd + default: + if year <= 0 { + return nil + } + d = fmt.Sprintf("%04d-01-01", year) + } + s := d + "T00:00:00Z" + return &s +} + +// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the +// field is omitted rather than sent as a meaningless epoch. +func jellyfinDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. +func channelLayout(n int) string { + switch n { + case 1: + return "mono" + case 2: + return "stereo" + case 6: + return "5.1" + case 8: + return "7.1" + default: + return "" + } +} + +// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file. +// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo +// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). +func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + return MediaSourceInfo{ + Id: EncodeID(mf.ID), + Protocol: "Http", + Container: mf.Suffix, + Size: mf.Size, + Name: mf.Title, + Type: "Default", + RunTimeTicks: TicksFromSeconds(mf.Duration), + Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. + SupportsDirectPlay: true, + SupportsDirectStream: true, + SupportsTranscoding: true, + IsRemote: false, + SupportsProbing: true, + MediaStreams: []MediaStream{{ + Type: "Audio", + Index: 0, + Codec: mf.Codec, + BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. + Channels: mf.Channels, + SampleRate: mf.SampleRate, + ChannelLayout: channelLayout(mf.Channels), + }}, + MediaAttachments: []any{}, + Formats: []string{}, + } +} + +func UserData(a model.Annotations, itemID string) *UserItemDataDto { + // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. + encodedID := EncodeID(itemID) + d := &UserItemDataDto{ + PlayCount: int(a.PlayCount), + IsFavorite: a.Starred, + Played: a.PlayCount > 0, + Key: encodedID, + ItemId: encodedID, + } + if a.Rating > 0 { + r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 + d.Rating = &r + } + if a.PlayDate != nil { + s := a.PlayDate.UTC().Format(time.RFC3339) + d.LastPlayedDate = &s + } + return d +} + +// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached +// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a +// plain list response); a nil fields set means neither. +func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { + item := BaseItemDto{ + Name: mf.Title, + Id: EncodeID(mf.ID), + Type: "Audio", + MediaType: "Audio", + IsFolder: false, + LocationType: "FileSystem", + HasLyrics: mf.Lyrics != "", + ParentId: EncodeID(mf.AlbumID), + Album: mf.Album, + AlbumId: EncodeID(mf.AlbumID), + AlbumArtist: mf.AlbumArtist, + Artists: []string{mf.Artist}, + RunTimeTicks: TicksFromSeconds(mf.Duration), + DateCreated: jellyfinDate(&mf.CreatedAt), + Container: mf.Suffix, + CanDownload: true, + BackdropImageTags: []string{}, + UserData: UserData(mf.Annotations, mf.ID), + } + if fields.Has("MediaSources") { + item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} + } + if fields.Has("SortName") { + item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) + } + // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown + // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; + // AlbumArtists the album artist. + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } + if mf.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} + } + if mf.Year > 0 { + item.ProductionYear = new(mf.Year) + } + item.PremiereDate = premiereDate(mf.Date, mf.Year) + if mf.TrackNumber > 0 { + item.IndexNumber = new(mf.TrackNumber) + } + if mf.DiscNumber > 0 { + item.ParentIndexNumber = new(mf.DiscNumber) + } + if len(mf.Genres) > 0 { + for _, g := range mf.Genres { + item.Genres = append(item.Genres, g.Name) + } + } else if mf.Genre != "" { + item.Genres = []string{mf.Genre} + } + // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. + if mf.AlbumID != "" { + item.AlbumPrimaryImageTag = mf.AlbumID + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} + } + return item +} + +func AlbumToBaseItem(al model.Album) BaseItemDto { + item := BaseItemDto{ + Name: al.Name, + Id: EncodeID(al.ID), + Type: "MusicAlbum", + IsFolder: true, + ParentId: EncodeID(al.AlbumArtistID), + AlbumArtist: al.AlbumArtist, + Album: al.Name, + ChildCount: new(al.SongCount), + SongCount: new(al.SongCount), + RunTimeTicks: TicksFromSeconds(al.Duration), + DateCreated: jellyfinDate(&al.CreatedAt), + ImageTags: map[string]string{"Primary": al.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), + } + if al.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} + item.ArtistItems = item.AlbumArtists + } + if al.MaxYear > 0 { + item.ProductionYear = new(al.MaxYear) + } + item.PremiereDate = premiereDate(al.Date, al.MaxYear) + if len(al.Genres) > 0 { + for _, g := range al.Genres { + item.Genres = append(item.Genres, g.Name) + } + } + return item +} + +func ArtistToBaseItem(ar model.Artist) BaseItemDto { + return BaseItemDto{ + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageTags: map[string]string{"Primary": ar.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), + } +} + +func GenreToBaseItem(g model.Genre) BaseItemDto { + return BaseItemDto{ + Name: g.Name, + Id: EncodeID(g.ID), + Type: "MusicGenre", + IsFolder: true, + BackdropImageTags: []string{}, + } +} + +// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. +func PlaylistToBaseItem(p model.Playlist) BaseItemDto { + // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. + // UpdatedAt versions it (Put bumps it on upload); over-invalidation only costs a refetch. + tag := fmt.Sprintf("%s-%x", p.ID, p.UpdatedAt.UnixMilli()) + return BaseItemDto{ + Name: p.Name, + Id: EncodeID(p.ID), + Type: "Playlist", + // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin + // stores them under its data folder), so without this its Playlists tab hides them all. + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageTags: map[string]string{"Primary": tag}, + ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}}, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), + } +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go new file mode 100644 index 000000000..bd351bee9 --- /dev/null +++ b/server/jellyfin/dto/mappers_test.go @@ -0,0 +1,280 @@ +package dto + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("mappers", func() { + It("maps a song to an Audio BaseItemDto", func() { + mf := model.MediaFile{ + ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, + Year: 1999, Duration: 60, Size: 2_500_000, + } + mf.PlayCount = 2 + mf.Starred = true + item := SongToBaseItem(mf, nil) + Expect(item.Type).To(Equal("Audio")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(item.IsFolder).To(BeFalse()) + Expect(item.LocationType).To(Equal("FileSystem")) + Expect(item.Id).To(Equal(EncodeID("song-1"))) + Expect(item.AlbumId).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("alb-1"))) + Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) + Expect(*item.IndexNumber).To(Equal(3)) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(item.UserData.Played).To(BeTrue()) + Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) + Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) + Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + }) + + Describe("Fields gating (matches real Jellyfin)", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, + SortTitle: "sort song", Lyrics: `[{"line":"la"}]`} + + It("omits MediaSources and SortName when Fields does not ask for them", func() { + item := SongToBaseItem(mf, nil) + Expect(item.MediaSources).To(BeNil()) + Expect(item.SortName).To(BeEmpty()) + }) + + It("includes MediaSources only when Fields=MediaSources", func() { + item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName")) + Expect(item.MediaSources).To(HaveLen(1)) + Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) + }) + + It("includes SortName (from the sort title) only when Fields=SortName", func() { + Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song")) + }) + + It("sets HasLyrics from the media file's lyrics", func() { + Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) + Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + }) + }) + + It("omits ImageBlurHashes when a song has no album", func() { + mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60} + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("sets DateCreated from the media file's CreatedAt", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) + }) + + It("omits DateCreated when CreatedAt is the zero time", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty()) + }) + + It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Song", + Artist: "The Band", ArtistID: "ar-1", + AlbumArtist: "Various", AlbumArtistID: "ar-2", + } + item := SongToBaseItem(mf, nil) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}})) + }) + + It("omits ArtistItems when the track has no artist id", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) + }) + + It("builds a MediaSourceInfo from a media file", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + Expect(src.Id).To(Equal(EncodeID("s1"))) + Expect(src.Size).To(Equal(int64(5242880))) + Expect(src.Container).To(Equal("mp3")) + Expect(src.Bitrate).To(Equal(320_000)) + Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) + Expect(src.Protocol).To(Equal("Http")) + Expect(src.SupportsDirectPlay).To(BeTrue()) + }) + + It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { + mf := model.MediaFile{ + ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, + Channels: 2, SampleRate: 44100, Codec: "mp3", + } + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(1)) + stream := src.MediaStreams[0] + Expect(stream.Type).To(Equal("Audio")) + Expect(stream.Channels).To(Equal(2)) + Expect(stream.SampleRate).To(Equal(44100)) + Expect(stream.BitRate).To(Equal(320_000)) + Expect(stream.Codec).To(Equal("mp3")) + Expect(stream.ChannelLayout).To(Equal("stereo")) + }) + + It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + b, err := json.Marshal(src) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"SupportsProbing":true`)) + Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`)) + Expect(j).To(ContainSubstring(`"RequiresOpening":false`)) + Expect(j).To(ContainSubstring(`"MediaAttachments":[]`)) + Expect(j).To(ContainSubstring(`"Formats":[]`)) + }) + + It("serializes MediaStream's required non-nullable bools, never omitted", func() { + stream := MediaStream{Type: "Audio", Index: 0} + b, err := json.Marshal(stream) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Type":"Audio"`)) + Expect(j).To(ContainSubstring(`"IsDefault":false`)) + Expect(j).To(ContainSubstring(`"IsInterlaced":false`)) + Expect(j).To(ContainSubstring(`"IsForced":false`)) + Expect(j).To(ContainSubstring(`"IsExternal":false`)) + Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`)) + Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) + }) + + It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { + mf := model.MediaFile{ + ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, + Duration: 60, + } + item := SongToBaseItem(mf, nil) + Expect(item.IndexNumber).To(BeNil()) + Expect(item.ParentIndexNumber).To(BeNil()) + }) + + It("maps PlayDate to UserData.LastPlayedDate", func() { + playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) + mf := model.MediaFile{ + ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", Duration: 60, + } + mf.PlayDate = &playDate + item := SongToBaseItem(mf, nil) + Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) + Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339))) + }) + + It("maps an album to a MusicAlbum folder item", func() { + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + item := AlbumToBaseItem(al) + Expect(item.Type).To(Equal("MusicAlbum")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("art-1"))) + Expect(item.AlbumArtists).To(HaveLen(1)) + Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1"))) + Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) + Expect(*item.ProductionYear).To(Equal(1999)) + Expect(*item.ChildCount).To(Equal(10)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) + Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + }) + + It("maps an artist to a MusicArtist folder item", func() { + ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} + item := ArtistToBaseItem(ar) + Expect(item.Type).To(Equal("MusicArtist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("art-1"))) + Expect(*item.AlbumCount).To(Equal(2)) + }) + + It("maps a genre to a MusicGenre folder item", func() { + g := model.Genre{ID: "genre-1", Name: "Rock"} + item := GenreToBaseItem(g) + Expect(item.Type).To(Equal("MusicGenre")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("genre-1"))) + Expect(item.Name).To(Equal("Rock")) + }) + + Describe("premiereDate", func() { + // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. + It("serializes a full date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007} + item := SongToBaseItem(mf, nil) + Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("pads a year-only date so clients can parse it", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) + }) + + It("pads a year-month date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("falls back to the year when no date tag exists", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) + }) + + It("is omitted when the track has no date at all", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil()) + }) + + It("is set on albums from their date, falling back to MaxYear", func() { + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + }) + }) + + It("maps a playlist to a Playlist BaseItemDto", func() { + p := model.Playlist{ + ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, + Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, + } + item := PlaylistToBaseItem(p) + Expect(item.Type).To(Equal("Playlist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("pl-1"))) + Expect(item.Name).To(Equal("Chill")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(*item.ChildCount).To(Equal(7)) + Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000))) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(*item.UserData.Rating).To(Equal(8.0)) + tag := item.ImageTags["Primary"] + Expect(tag).ToNot(BeEmpty()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag)) + Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6)) + }) + + It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() { + p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + before := PlaylistToBaseItem(p) + p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) + after := PlaylistToBaseItem(p) + + // Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover. + Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"])) + Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"])) + }) + + It("keeps the playlist image tag stable when nothing changed", func() { + p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) + }) +}) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go new file mode 100644 index 000000000..b1ad850e3 --- /dev/null +++ b/server/jellyfin/e2e/annotations_test.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + BeforeEach(func() { setupTestDB() }) + + itemUserData := func(id string) *dto.UserItemDataDto { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + return item.UserData + } + + Describe("favorites", func() { + It("marks and unmarks an album as favorite", func() { + id := albumID("Abbey Road") + + var marked dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("marks a song as favorite", func() { + id := songID("So What") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + }) + + It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() { + id := songID("Come Together") + + var marked dto.UserItemDataDto + parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("filters items to favorites only", func() { + post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Abbey Road")) + }) + + It("marks and lists a playlist as favorite", func() { + id := createPlaylist("Favorite Mix", nil) + Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK)) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Favorite Mix")) + }) + + It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() { + // Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not + // Filters=IsFavorite), combined with ArtistIds. + post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "") + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true")) + Expect(names(q.Items)).To(ConsistOf("Help!")) + }) + + It("returns 404 when favoriting an unknown item", func() { + Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /UserItems/{id}/UserData", func() { + It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() { + id := songID("So What") + post("/Users/admin-1/FavoriteItems/"+enc(id), "") + + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data) + Expect(data.IsFavorite).To(BeTrue()) + Expect(data.ItemId).To(Equal(enc(id))) + }) + + It("returns a valid (unfavorited) UserData for an item with no annotations", func() { + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data) + Expect(data.IsFavorite).To(BeFalse()) + Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue")))) + }) + + It("returns 404 for an unknown item", func() { + Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ratings", func() { + It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { + id := albumID("IV") + + var set dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) + Expect(set.Rating).ToNot(BeNil()) + Expect(*set.Rating).To(Equal(float64(10))) + Expect(*itemUserData(id).Rating).To(Equal(float64(10))) + + // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` + // would leave the stale value. + var cleared dto.UserItemDataDto + parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) + Expect(cleared.Rating).To(BeNil()) + Expect(itemUserData(id).Rating).To(BeNil()) + }) + + It("sets and reads a playlist rating", func() { + id := createPlaylist("Rated Mix", nil) + Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) + Expect(*itemUserData(id).Rating).To(Equal(float64(8))) + }) + + It("clamps an out-of-range rating to the valid domain", func() { + id := albumID("Help!") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) + // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. + Expect(data.Rating).ToNot(BeNil()) + Expect(*data.Rating).To(Equal(float64(10))) + }) + }) +}) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go new file mode 100644 index 000000000..383dbab83 --- /dev/null +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -0,0 +1,113 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse endpoints", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /AudioMuseAI/info", func() { + It("returns version and available endpoints", func() { + var body struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` + } + parseInto(get("/AudioMuseAI/info"), &body) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + w := get("/AudioMuseAI/health") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/similar_tracks", func() { + It("maps provider results to seeded tracks, encoding item ids", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("So What"), Similarity: 0.5}, + } + var body []struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` + } + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) + Expect(body).To(HaveLen(2)) + Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) + Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title))) + }) + + It("collapses to one track per artist by default", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("Come Together"), Similarity: 0.5}, + } + var body []map[string]any + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body) + Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles + }) + + It("returns an empty array (not null) when there are no results", func() { + sonicProviderFake.similar = nil + w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together"))) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/find_path", func() { + It("returns 400 with the exact message when a required id is missing", func() { + w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something"))) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns the path and summed total_distance", func() { + sonicProviderFake.path = []sonic.SimilarResult{ + {Song: songAgent("Come Together"), Similarity: 1.5}, + {Song: songAgent("So What"), Similarity: 2.0}, + } + var body struct { + Path []struct { + ItemID string `json:"item_id"` + Title string `json:"title"` + } `json:"path"` + TotalDistance float64 `json:"total_distance"` + } + parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go new file mode 100644 index 000000000..7128972ba --- /dev/null +++ b/server/jellyfin/e2e/auth_test.go @@ -0,0 +1,120 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Authentication", func() { + BeforeEach(func() { setupTestDB() }) + + authenticate := func(username, pw string) *httptest.ResponseRecorder { + body := `{"Username":"` + username + `","Pw":"` + pw + `"}` + return rawReq("POST", "/Users/AuthenticateByName", body) + } + + Describe("POST /Users/AuthenticateByName", func() { + It("authenticates a valid user and returns a usable token", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User).ToNot(BeNil()) + Expect(res.User.Name).To(Equal("admin")) + Expect(res.User.Id).To(Equal(enc("admin-1"))) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + Expect(res.ServerId).ToNot(BeEmpty()) + + // The returned token must actually authenticate a protected request. + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + }) + + It("marks a non-admin user's policy as non-administrator", func() { + w := authenticate("regular", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + }) + + It("rejects a wrong password", func() { + Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password", func() { + Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an unknown user", func() { + Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a malformed body", func() { + Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("GET /Users/Public", func() { + publicUsers := func() []dto.UserDto { + w := rawReq("GET", "/Users/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + parseInto(w, &users) + return users + } + + It("returns an empty list when no users are exposed", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users to an unauthenticated caller, without policy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "regular" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("regular")) + Expect(users[0].Id).To(Equal(enc("regular-1"))) + Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login + }) + }) + + Describe("current user", func() { + It("returns the caller from GET /Users/Me", func() { + var u dto.UserDto + parseInto(getAs(regularUser, "/Users/Me"), &u) + Expect(u.Name).To(Equal("regular")) + Expect(u.Id).To(Equal(enc("regular-1"))) + }) + + It("returns the caller from GET /Users/{userId}", func() { + var u dto.UserDto + parseInto(get("/Users/admin-1"), &u) + Expect(u.Name).To(Equal("admin")) + }) + }) + + Describe("auth enforcement", func() { + It("rejects a protected request with no token", func() { + Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a protected request with a bogus token", func() { + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", "not-a-valid-jwt") + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go new file mode 100644 index 000000000..0b2769856 --- /dev/null +++ b/server/jellyfin/e2e/browsing_test.go @@ -0,0 +1,439 @@ +package e2e + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func names(items []dto.BaseItemDto) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Name + } + return out +} + +var _ = Describe("Browsing", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /UserViews", func() { + It("returns the user's libraries as CollectionFolders", func() { + q := queryResult(get("/UserViews")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Music Library")) + Expect(q.Items[0].Type).To(Equal("CollectionFolder")) + Expect(q.Items[0].CollectionType).To(Equal("music")) + }) + }) + + Describe("GET /Items by type", func() { + It("lists all albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("lists all songs with Audio type and an AlbumId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(7)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Audio")) + Expect(it.MediaType).To(Equal("Audio")) + Expect(it.LocationType).To(Equal("FileSystem")) + Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it + Expect(it.AlbumId).ToNot(BeEmpty()) + } + }) + + // Real Jellyfin omits MediaSources from a plain list response, returning it only when the + // client asks via Fields=MediaSources (Finamp's download dialog does). + It("omits MediaSources unless Fields=MediaSources is requested", func() { + plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + for _, it := range plain.Items { + Expect(it.MediaSources).To(BeEmpty()) + } + withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources")) + for _, it := range withSources.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + + It("lists all album artists", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(4)) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + }) + + It("lists all genres", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(3)) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("returns no playlists when none exist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }) + + It("defaults to albums when IncludeItemTypes is unrecognized", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + }) + + Describe("ParentId browsing", func() { + It("browses an artist's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("browses an album's tracks in track order by default", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) + Expect(q.TotalRecordCount).To(Equal(2)) + // Track order (Something=1, Come Together=2) differs from alphabetical title order, + // proving the sort is by track number, not name. + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + Expect(*q.Items[0].IndexNumber).To(Equal(1)) + Expect(*q.Items[1].IndexNumber).To(Equal(2)) + }) + + // "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order. + It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) + + "&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending")) + got := names(q.Items) + Expect(got).To(HaveLen(3)) + Expect(got[:2]).To(ConsistOf("Come Together", "Something")) + Expect(got[2]).To(Equal("Help!")) + }) + + It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + }) + + // Finamp's download sync asks a library for the tracks outside any album this way; answering + // with every track would stream the whole library. + Describe("Recursive=false", func() { + lib1 := enc("1") + + It("returns no songs for a library parent", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(BeZero()) + }) + + It("still lists the library's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("still lists an album's tracks", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + }) + + // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds + // for the actual artist filter, not ParentId=. + Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { + lib1 := enc("1") + + It("filters albums by AlbumArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by ArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("filters albums by a single-album artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin")))) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("filters songs by a single-track artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) + Expect(names(q.Items)).To(ConsistOf("So What")) + }) + + // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears + // on, which must exclude their own discography (albums where they are the album artist). + It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) + Expect(names(q.Items)).To(ConsistOf("Singles")) + }) + + It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) + Expect(names(q.Items)).ToNot(ContainElement("Help!")) + }) + }) + + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. + Describe("genre filtering (GenreIds)", func() { + lib1 := enc("1") + + It("filters albums by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("filters songs by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(4)) + }) + + It("matches any of multiple comma-separated GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("returns nothing for an unknown genre id", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + + It("filters album artists by GenreIds on /Artists/AlbumArtists", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("matches album artists of any of multiple GenreIds", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist")) + }) + + It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin")) + }) + + It("returns no artists for an unknown genre id", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase + // (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin + // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an + // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. + Describe("camelCase query params (Jellify / JS SDK)", func() { + lib1 := enc("1") + + It("filters albums by camelCase albumArtistIds", func() { + q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by camelCase artistIds", func() { + q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + + It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + }) + + Describe("search, batch and pagination", func() { + It("searches albums by term", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("batch-fetches specific items by Ids", func() { + ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) + q := queryResult(get("/Items?ids=" + ids)) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) + }) + + // Finamp restores its saved queue with ids truncated to 16 bytes (see README). + Describe("Finamp-truncated ids (saved queue restore)", func() { + It("resolves a truncated id by unique prefix and echoes the requested id", func() { + full := songID("Come Together") + truncated := full[:16] + q := queryResult(get("/Items?ids=" + enc(truncated))) + Expect(names(q.Items)).To(ConsistOf("Come Together")) + // Finamp matches restored items by its stored ids, so the requested id must be echoed. + Expect(q.Items[0].Id).To(Equal(enc(truncated))) + }) + + It("batch-resolves a mixed list of truncated and full ids, keeping order", func() { + ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16]) + q := queryResult(get("/Items?ids=" + ids)) + Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"})) + }) + + It("streams a track by its truncated id", func() { + full := songID("So What") + w := get("/Audio/" + enc(full[:16]) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(full)) + }) + + It("still 404s for a truncated id matching nothing", func() { + Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + It("applies Limit while reporting the full TotalRecordCount", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) + Expect(q.Items).To(HaveLen(2)) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("pages distinct items via StartIndex", func() { + p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) + Expect(p1.Items).To(HaveLen(2)) + Expect(p2.Items).To(HaveLen(2)) + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + }) + + It("merges multiple types into one paginated result", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs + }) + + // Chaining the per-type cursors must preserve the merged order. + It("streams an unbounded multi-type merge, honoring StartIndex", func() { + all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(all.Items).To(HaveLen(12)) + + skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2")) + Expect(skipped.Items).To(HaveLen(10)) + Expect(skipped.TotalRecordCount).To(Equal(12)) + Expect(skipped.StartIndex).To(Equal(2)) + Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:])) + }) + + // Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing. + It("pages songs via StartIndex/Limit while reporting the full total", func() { + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName")) + Expect(all.TotalRecordCount).To(Equal(7)) + Expect(all.Items).To(HaveLen(7)) + + p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3")) + Expect(p1.Items).To(HaveLen(3)) + Expect(p2.Items).To(HaveLen(3)) + Expect(p1.TotalRecordCount).To(Equal(7)) + // The two pages are distinct and match the head of the unpaged, identically-sorted list. + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6])) + }) + }) + + Describe("GET /Items/{id}", func() { + It("resolves an album", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) + Expect(item.Name).To(Equal("Kind of Blue")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("resolves a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.Type).To(Equal("Audio")) + }) + + It("includes a parseable DateCreated (Date Added) on a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.DateCreated).ToNot(BeEmpty()) + _, err := time.Parse(time.RFC3339, item.DateCreated) + Expect(err).ToNot(HaveOccurred()) + }) + + It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.ArtistItems).ToNot(BeEmpty()) + Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis")) + Expect(item.ArtistItems[0].Id).ToNot(BeEmpty()) + Expect(item.AlbumArtists).ToNot(BeEmpty()) + Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) + }) + + It("resolves an artist", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) + Expect(item.Type).To(Equal("MusicArtist")) + }) + + It("returns 404 for an unknown id", func() { + Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Users/{userId}/Items/Latest", func() { + It("returns recent albums as a bare array, respecting Limit", func() { + var items []dto.BaseItemDto + parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) + Expect(items).To(HaveLen(3)) + for _, it := range items { + Expect(it.Type).To(Equal("MusicAlbum")) + } + }) + }) + + Describe("GET /Artists and /Genres", func() { + It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { + names := names(queryResult(get("/Artists/AlbumArtists")).Items) + Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + Expect(names).ToNot(ContainElement("Featured Guest")) + }) + + It("lists performing artists on /Artists (includes a track's guest artist)", func() { + names := names(queryResult(get("/Artists")).Items) + Expect(names).To(ContainElement("Featured Guest")) + Expect(names).To(ContainElement("Solo Artist")) + }) + + It("returns different lists for album artists and performing artists", func() { + aa := names(queryResult(get("/Artists/AlbumArtists")).Items) + ar := names(queryResult(get("/Artists")).Items) + Expect(aa).ToNot(Equal(ar)) + }) + + It("lists genres", func() { + q := queryResult(get("/Genres")) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("pages genres with StartIndex/Limit and still reports the full total", func() { + q := queryResult(get("/Genres?StartIndex=1&Limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go new file mode 100644 index 000000000..ea47dcdaa --- /dev/null +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -0,0 +1,416 @@ +// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API. +// +// These tests exercise the full HTTP request/response cycle through the Jellyfin API router, +// using a real SQLite database and real repository implementations while stubbing out external +// services (artwork, streaming, transcoding) with spy/noop implementations. +// +// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite +// database, seeds two users (admin + regular) and one library backed by a fake in-memory +// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores +// that snapshot and builds a fresh jellyfin.Router. +// +// # Seeded library (see buildTestFS) +// +// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) +// Rock/The Beatles/Help!/ 01 Help! (1965) +// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) +// Jazz/Miles Davis/Kind of Blue/01 So What (1959) +// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest") +// +// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists), +// 3 genres (Rock=4, Jazz=1, Pop=2). +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "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/server/events" + "github.com/navidrome/navidrome/server/jellyfin" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API E2E Suite") +} + +// Easy aliases for the storagetest package +type _t = map[string]any + +var ( + template = storagetest.Template + track = storagetest.Track +) + +// Shared test state +var ( + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + sonicProviderFake *fakeSonicProvider + goldenDB *harness.DB + dataFolder string + + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + } + + regularUser = model.User{ + ID: "regular-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + } +) + +// buildTestFS creates the seeded test filesystem (see package doc for totals). +func buildTestFS() storagetest.FakeFS { + abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) + ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) + + return harness.CreateFS(fstest.MapFS{ + // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, + // Come Together=2) so tests can tell track-order sorting apart from title sorting. + "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), + "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), + "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), + // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a + // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. + "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), + }) +} + +// --- Request helpers --- + +// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. +func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +// rawReq performs a request with no authentication (for public routes). +func rawReq(method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") } +func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") } +func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) } +func postAs(u model.User, path, body string) *httptest.ResponseRecorder { + return jReq(u, "POST", path, body) +} +func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } +func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } + +// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). +func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", path, bytes.NewReader(body)) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + r.Header.Set("Content-Type", contentType) + router.ServeHTTP(w, r) + return w +} + +// parseInto asserts a 200 and unmarshals the JSON body into target. +func parseInto(w *httptest.ResponseRecorder, target any) { + Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) + Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed()) +} + +// queryResult asserts a 200 and returns the parsed QueryResult. +func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { + var q dto.QueryResult + parseInto(w, &q) + return q +} + +// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a +// client would send) and returns its decoded Navidrome id. +func createPlaylist(name string, encodedIds []string) string { + return createPlaylistAs(adminUser, name, encodedIds...) +} + +// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. +func createPlaylistAs(user model.User, name string, encodedIds ...string) string { + if encodedIds == nil { + encodedIds = []string{} + } + body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) + Expect(err).ToNot(HaveOccurred()) + var res map[string]string + parseInto(postAs(user, "/Playlists", string(body)), &res) + Expect(res["Id"]).ToNot(BeEmpty()) + return dto.DecodeID(res["Id"]) +} + +// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- + +func enc(id string) string { return dto.EncodeID(id) } + +// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than +// guessing repository filter column names. + +func albumID(name string) string { + albums, err := ds.Album(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range albums { + if a.Name == name { + return a.ID + } + } + Fail("album not found: " + name) + return "" +} + +func songID(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return mf.ID + } + } + Fail("song not found: " + title) + return "" +} + +func artistID(name string) string { + artists, err := ds.Artist(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range artists { + if a.Name == name { + return a.ID + } + } + Fail("artist not found: " + name) + return "" +} + +func genreID(name string) string { + genres, err := ds.Genre(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, g := range genres { + if g.Name == name { + return g.ID + } + } + Fail("genre not found: " + name) + return "" +} + +// --- Suite lifecycle --- + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + dataFolder = filepath.Join(GinkgoT().TempDir(), "data") + Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed()) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + + buildTestFS() + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from +// BeforeEach in each test container. +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + + goldenDB.Restore() + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + streamerSpy = &harness.SpyStreamer{} + artworkSpy = &spyArtwork{} + providerFake = &fakeExternalProvider{} + sonicProviderFake = &fakeSonicProvider{} + sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds)) + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + router = jellyfin.New( + ds, + artworkSpy, + streamerSpy, + decider, + core.NewPlayers(ds), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + providerFake, + sonicSvc, + ) +} + +// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return +// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses +// are overridden — the embedded interface panics for anything else, flagging unexpected calls. +type fakeExternalProvider struct { + external.Provider + similarArtists model.Artists + similarSongs model.MediaFiles +} + +func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) { + return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil +} + +func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return f.similarSongs, nil +} + +// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are +// active in e2e; the provider it hands back returns test-configured results. +type fakeSonicLoader struct{ provider sonic.Provider } + +func (f *fakeSonicLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return []string{"fake"} + } + return nil +} + +func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) { + return f.provider, true +} + +// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the +// agents.Song results; the real matcher resolves them back to seeded library tracks. +type fakeSonicProvider struct { + similar []sonic.SimilarResult + path []sonic.SimilarResult +} + +func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.similar, nil +} + +func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.path, nil +} + +// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an +// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. +func songAgent(title string) agents.Song { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}} + } + } + Fail("song not found: " + title) + return agents.Song{} +} + +// --- Spy/noop dependencies (shared ones live in tests/harness) --- + +// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the +// resolved ArtworkID and that resolution runs under an elevated (admin) context. +type spyArtwork struct { + lastID string + lastCtx context.Context + data []byte +} + +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound +} + +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + s.lastID = id + s.lastCtx = c + d := s.data + if d == nil { + d = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil +} + +var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go new file mode 100644 index 000000000..0c53d75f3 --- /dev/null +++ b/server/jellyfin/e2e/images_test.go @@ -0,0 +1,74 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite +// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert +// resolution and elevation without needing real image processing. +var _ = Describe("Item images", func() { + BeforeEach(func() { setupTestDB() }) + + It("resolves an album's Primary image", func() { + id := albumID("Abbey Road") + w := get("/Items/" + enc(id) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves an artist's Primary image", func() { + id := artistID("Miles Davis") + Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves a private playlist's cover for its owner under an elevated context", func() { + // The route carries no user in ctx (public); the owner is identified by the request token, + // and resolution then runs elevated so the visibility filter doesn't eat the cover. + plID := createPlaylist("Private Mix", nil) + w := get("/Items/" + enc(plID) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + + u, ok := request.UserFrom(artworkSpy.lastCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) + + It("serves images without authentication (public route)", func() { + id := albumID("IV") + w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + }) + + Describe("private playlist covers", func() { + It("does not resolve a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("does not resolve a private playlist's cover for another user", func() { + plID := createPlaylist("Secret Mix", nil) + w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("resolves a public playlist's cover for anyone", func() { + plID := createPlaylist("Shared Mix", nil) + Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + }) + }) +}) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go new file mode 100644 index 000000000..015d82d91 --- /dev/null +++ b/server/jellyfin/e2e/multiuser_test.go @@ -0,0 +1,64 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-user access control", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("library scoping", func() { + It("lets a library member browse its content", func() { + q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("hides all content from a user with no library access", func() { + noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"} + Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername("noaccess") + Expect(err).ToNot(HaveOccurred()) + + q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("private playlists", func() { + It("does not expose another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + + // Owner sees it. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + // A different user does not. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + // And can't read its items. + Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) + }) + + It("does not let a non-owner delete another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — + // the API never reveals that someone else's private playlist exists. + Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) + // Still present for the owner. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("does not let a non-owner annotate another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) + Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) + }) + + It("lets each user manage their own playlist", func() { + regularPl := createPlaylistAs(regularUser, "Regular's Mix") + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go new file mode 100644 index 000000000..d2ff49db9 --- /dev/null +++ b/server/jellyfin/e2e/playlists_test.go @@ -0,0 +1,311 @@ +package e2e + +import ( + "bytes" + "image" + jpeglib "image/jpeg" + "net/http" + "os" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + BeforeEach(func() { setupTestDB() }) + + playlistItems := func(plID string) dto.QueryResult { + return queryResult(get("/Playlists/" + enc(plID) + "/Items")) + } + + Describe("create", func() { + It("creates an empty playlist", func() { + plID := createPlaylist("Empty", nil) + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeFalse()) + Expect(info.Shares).To(BeEmpty()) + Expect(info.ItemIds).To(BeEmpty()) + }) + + It("creates a playlist from song ids", func() { + plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("expands an album id into its tracks", func() { + plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + + It("expands an artist id into its tracks", func() { + plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) + }) + }) + + Describe("items", func() { + It("tags each entry with a PlaylistItemId", func() { + plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) + q := playlistItems(plID) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].Type).To(Equal("Audio")) + Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) + }) + }) + + Describe("add and remove", func() { + It("adds a song by id", func() { + plID := createPlaylist("Add", nil) + Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("adds an album (expanding to its tracks)", func() { + plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) + post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) + }) + + // Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a + // comma-joined value; all ids must be added, not just the first. + It("adds multiple songs sent as repeated ids params", func() { + plID := createPlaylist("Multi", nil) + url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + + "&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!")) + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) + }) + + It("removes an entry by its PlaylistItemId", func() { + plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) + entryID := playlistItems(plID).Items[0].PlaylistItemId + Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("removes multiple entries sent as repeated entryIds params", func() { + plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + items := playlistItems(plID).Items + url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId + Expect(del(url).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("users", func() { + It("reports the current user as an editor", func() { + plID := createPlaylist("Perms", nil) + var perms []dto.PlaylistUserPermissions + parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) + Expect(perms).To(HaveLen(1)) + Expect(perms[0].UserId).To(Equal(enc("admin-1"))) + Expect(perms[0].CanEdit).To(BeTrue()) + }) + }) + + Describe("listing", func() { + It("lists a created playlist advertising a Primary image tag", func() { + createPlaylist("Listed", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Listed")) + Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) + }) + + It("sorts playlists by name when SortBy=SortName", func() { + createPlaylist("Charlie", nil) + createPlaylist("Alpha", nil) + createPlaylist("Bravo", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"})) + }) + }) + + // Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists + // playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item + // whose CollectionType is "playlists", its query resolves undefined and React Query retries in a + // backoff loop that stalls the home screen. + Describe("playlists library folder (ManualPlaylistsFolder)", func() { + It("returns a synthetic playlists folder with CollectionType=playlists", func() { + q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].CollectionType).To(Equal("playlists")) + Expect(q.Items[0].Id).To(Equal(enc("playlists"))) + }) + + It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { + createPlaylist("My Mix", nil) + q := queryResult(get("/Items?parentId=" + enc("playlists"))) + Expect(names(q.Items)).To(ContainElement("My Mix")) + Expect(q.Items[0].Type).To(Equal("Playlist")) + // Jellify keeps only playlists whose Path contains "data". + Expect(q.Items[0].Path).To(ContainSubstring("data")) + }) + + It("resolves the synthetic playlists folder by its own advertised id", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc("playlists")), &item) + Expect(item.Type).To(Equal("ManualPlaylistsFolder")) + Expect(item.CollectionType).To(Equal("playlists")) + Expect(item.Id).To(Equal(enc("playlists"))) + }) + }) + + // Real Jellyfin returns a playlist's children for /Items?ParentId= with no + // IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way. + Describe("browsing a playlist via the generic /Items path", func() { + It("lists the playlist's tracks for a typeless ParentId query", func() { + plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID))) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("pages the playlist's tracks", func() { + plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + // Jellify opens a playlist with ParentId=&IncludeItemTypes=Audio&Recursive=false. + // The playlist id must resolve to its tracks, not be treated as an album id (which returns none). + It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() { + plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + }) + }) + + Describe("cover art", func() { + // A real (decodable) image: the upload endpoint validates by decoding, like the native one. + var jpeg []byte + BeforeEach(func() { + var buf bytes.Buffer + Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + jpeg = buf.Bytes() + }) + + It("uploads and removes a playlist cover", func() { + plID := createPlaylist("Cover", nil) + + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + pls, err := ds.Playlist(ctx).Get(plID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.UploadedImage).ToNot(BeEmpty()) + _, statErr := os.Stat(pls.UploadedImagePath()) + Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") + + Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) + pls, _ = ds.Playlist(ctx).Get(plID) + Expect(pls.UploadedImage).To(BeEmpty()) + }) + + It("rejects cover upload for a non-playlist item", func() { + Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNotImplemented)) + }) + + // Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the + // tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever. + It("rotates the playlist's image tag and blurhash after a cover upload", func() { + plID := createPlaylist("Cover Tag", nil) + imageTag := func() string { + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items).To(HaveLen(1)) + return q.Items[0].ImageTags["Primary"] + } + before := imageTag() + Expect(before).ToNot(BeEmpty()) + + time.Sleep(2 * time.Millisecond) // UpdatedAt has millisecond resolution in the tag + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + after := imageTag() + Expect(after).ToNot(Equal(before)) + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after)) + }) + }) + + Describe("update", func() { + It("makes a playlist public", func() { + plID := createPlaylist("Make Public", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeTrue()) + // Now visible to other users. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("renames a playlist", func() { + plID := createPlaylist("Old Name", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("New Name")) + }) + + It("replaces the track list when Ids are provided", func() { + plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))}) + // Replace with a single different track. + Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + }) + + It("clears the track list when an explicit empty Ids array is sent", func() { + plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(0)) + }) + + It("leaves the track list intact when Ids is omitted (metadata-only update)", func() { + plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("applies Name and IsPublic sent together with a track replacement", func() { + plID := createPlaylist("Combo", []string{enc(songID("Come Together"))}) + body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}` + Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("Combo Renamed")) + Expect(pls.Public).To(BeTrue()) + }) + + It("forbids a non-owner from updating a public playlist", func() { + plID := createPlaylist("Owned", nil) + post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user + Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("delete", func() { + It("deletes a playlist", func() { + plID := createPlaylist("ToDelete", nil) + Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + }) + + It("returns 404 when deleting a non-playlist item", func() { + Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go new file mode 100644 index 000000000..49faad342 --- /dev/null +++ b/server/jellyfin/e2e/routing_test.go @@ -0,0 +1,31 @@ +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Routing", func() { + BeforeEach(func() { setupTestDB() }) + + It("routes authenticated endpoints case-insensitively", func() { + // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. + lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(lower.TotalRecordCount).To(Equal(5)) + }) + + It("returns a JSON 404 for an unknown route", func() { + w := get("/Nonexistent/Route") + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) + Expect(w.Body.String()).To(ContainSubstring("{}")) + }) + + It("returns 404 for an unsupported method on a known path", func() { + // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. + w := jReq(adminUser, "PUT", "/Items", "") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go new file mode 100644 index 000000000..6c26569fc --- /dev/null +++ b/server/jellyfin/e2e/search_test.go @@ -0,0 +1,76 @@ +package e2e + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the +// tricky case: they have no library_id column, so the repo's Search does its own scope handling. +var _ = Describe("Search", func() { + BeforeEach(func() { setupTestDB() }) + + lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + + Describe("artists", func() { + It("searches all album artists", func() { + q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("searches album artists scoped to a library (ParentId)", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("returns an empty result for a non-matching term", func() { + q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + Describe("albums and songs", func() { + It("searches albums scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("searches songs scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) + Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) + }) + }) + + Describe("pagination totals", func() { + It("reports the search match count, not the unfiltered library count", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey&Limit=50")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(1)) // not the 5-album library total + }) + + It("reaches the true total when paging song search results", func() { + // "So" prefix-matches several songs (titles and Solo Artist's tracks); learn the true + // count from an unpaged query, then walk one-item pages: the reported total must keep + // the client paging until the last match and stop it exactly there. + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So")) + total := all.TotalRecordCount + Expect(total).To(Equal(len(all.Items))) + Expect(total).To(BeNumerically(">=", 2)) + + var collected []string + for start := range total { + page := queryResult(get(fmt.Sprintf("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So&Limit=1&StartIndex=%d", start))) + Expect(page.Items).To(HaveLen(1)) + if start+1 < total { + Expect(page.TotalRecordCount).To(BeNumerically(">", start+1)) // more remain: keep paging + } else { + Expect(page.TotalRecordCount).To(Equal(total)) // last page: exact, so the client stops + } + collected = append(collected, page.Items[0].Name) + } + Expect(collected).To(ConsistOf(names(all.Items))) + }) + }) +}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go new file mode 100644 index 000000000..2057b43c6 --- /dev/null +++ b/server/jellyfin/e2e/sessions_test.go @@ -0,0 +1,62 @@ +package e2e + +import ( + "net/http" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sessions", func() { + BeforeEach(func() { setupTestDB() }) + + ticks := func(ms int64) int64 { return ms * 10_000 } + reportBody := func(itemID string, positionTicks int64) string { + return `{"ItemId":"` + enc(itemID) + `","PositionTicks":` + strconv.FormatInt(positionTicks, 10) + `}` + } + + Describe("playback reporting", func() { + It("accepts a playback start report", func() { + Expect(post("/Sessions/Playing", reportBody(songID("Come Together"), 0)).Code).To(Equal(http.StatusNoContent)) + }) + + It("accepts a playback progress report", func() { + Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"), ticks(5000))).Code).To(Equal(http.StatusNoContent)) + }) + + It("counts a play stopped past the threshold", func() { + id := songID("So What") + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) + + mf, err = ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(BeNumerically(">=", 1)) + }) + + It("does not count a brief play stopped before the threshold", func() { + // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip + // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% + // threshold is always well above 1s. + id := songID("Help!") + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) + + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("capabilities", func() { + It("acknowledges POST /Sessions/Capabilities", func() { + Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) + }) + + It("acknowledges POST /Sessions/Capabilities/Full", func() { + Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go new file mode 100644 index 000000000..43ef857fa --- /dev/null +++ b/server/jellyfin/e2e/similar_test.go @@ -0,0 +1,134 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Similar", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Artists/{id}/Similar", func() { + It("returns the provider's similar artists, excluding ones not in the library", func() { + providerFake.similarArtists = model.Artists{ + {ID: "z", Name: "Led Zeppelin"}, + {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded + } + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Led Zeppelin")) + Expect(q.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("returns an empty result (not 404) when the provider has nothing", func() { + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("GET /Items/{id}/Similar", func() { + It("returns similar songs for a track", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Similar Song")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("In Library")) + }) + + It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, + {ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once + {ID: "x3", AlbumID: albumID("Kind of Blue")}, + } + q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) + Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) + Expect(q.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("excludes similar albums from libraries the user can't access", func() { + // Seed an album in a second library the regular user has no access to, and point a + // provider similar-song at it. + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2} + Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed()) + + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible + {ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser + } + q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { + q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. + Describe("GET /Items/{id}/InstantMix", func() { + It("returns the seed track first, followed by similar songs", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("does not duplicate the seed when the provider returns it", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: songID("So What"), Title: "So What", LibraryID: 1}, + {ID: "x1", Title: "Similar Song", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + }) + + It("caps the mix at the requested limit", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "S1", LibraryID: 1}, + {ID: "x2", Title: "S2", LibraryID: 1}, + {ID: "x3", Title: "S3", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) + Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) + }) + + It("returns a mix of the provider's similar songs for an artist seed", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) + }) + + It("returns an empty result (not 404) for an unknown item", func() { + w := get("/Items/" + enc("does-not-exist") + "/InstantMix") + Expect(w.Code).To(Equal(200)) + Expect(queryResult(w).Items).To(BeEmpty()) + }) + + It("returns only the seed when the provider has nothing", func() { + q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Help!"})) + }) + }) +}) diff --git a/server/jellyfin/e2e/smoke_test.go b/server/jellyfin/e2e/smoke_test.go new file mode 100644 index 000000000..b26955632 --- /dev/null +++ b/server/jellyfin/e2e/smoke_test.go @@ -0,0 +1,49 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded +// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files. +var _ = Describe("Smoke", func() { + BeforeEach(func() { setupTestDB() }) + + It("serves public system info without auth", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info).To(HaveKey("ServerName")) + Expect(info).To(HaveKey("Version")) + }) + + It("rejects an authenticated endpoint without a token", func() { + w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists the seeded albums for an authenticated user", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("MusicAlbum")) + names = append(names, it.Name) + } + Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("resolves a seeded album id round-trip (encoded in the URL)", func() { + id := albumID("Abbey Road") + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + Expect(item.Id).To(Equal(enc(id))) + Expect(item.Name).To(Equal("Abbey Road")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) +}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go new file mode 100644 index 000000000..c096d904a --- /dev/null +++ b/server/jellyfin/e2e/streaming_test.go @@ -0,0 +1,128 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Streaming", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Audio/{id}/stream", func() { + It("streams the requested track", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("fake audio data")) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("streams via the /universal endpoint", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("serves the stream.{container} path form", func() { + id := songID("Help!") + Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("forces raw format when static=true", func() { + // With ffmpeg unavailable the decider direct-plays regardless, but static=true must + // never resolve to a transcode. + id := songID("Help!") + get("/Audio/" + enc(id) + "/stream?static=true") + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("returns 404 for an unknown track", func() { + Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { + It("returns a VOD playlist whose segment streams through the transcode pipeline", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/main.m3u8?audioCodec=aac&audioBitRate=320000") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + + // Fetch the advertised segment like an HLS player would. + var segment string + for _, line := range strings.Split(body, "\n") { + if line != "" && !strings.HasPrefix(line, "#") { + segment = line + } + } + Expect(segment).To(HavePrefix("stream.aac?")) + Expect(get("/Audio/" + enc(id) + "/" + segment).Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + Expect(streamerSpy.LastRequest.Format).To(Equal("aac")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(320)) + }) + + It("is reachable with Jellyfin's case-insensitive routing", func() { + id := songID("Come Together") + Expect(get("/audio/" + enc(id) + "/Main.m3u8").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("direct-file endpoints", func() { + It("serves /Items/{id}/File as direct play (raw)", func() { + id := songID("Something") + w := get("/Items/" + enc(id) + "/File") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("serves /Items/{id}/Download", func() { + id := songID("Something") + Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("PlaybackInfo", func() { + It("returns a single direct-play MediaSource via GET", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) + Expect(info.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns a MediaSource via POST", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + }) + + It("embeds a self-authenticating TranscodingUrl (for native players that omit auth headers)", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + streamURL := info.MediaSources[0].TranscodingUrl + // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute + // host path hits the mounted router. + Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + Expect(streamURL).To(ContainSubstring("api_key=")) + // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e + // router is mounted at the root, so strip the /jellyfin prefix before replaying. + replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) + w := rawReq("GET", replayURL, "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go new file mode 100644 index 000000000..d4d2f2777 --- /dev/null +++ b/server/jellyfin/e2e/system_test.go @@ -0,0 +1,55 @@ +package e2e + +import ( + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /System/Info/Public", func() { + It("returns public server info without authentication", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info["ServerName"]).To(HavePrefix("Navidrome")) + Expect(info["ProductName"]).To(Equal("Jellyfin Server")) + Expect(info["StartupWizardCompleted"]).To(BeTrue()) + Expect(info["Id"]).ToNot(BeEmpty()) + Expect(info["Version"]).ToNot(BeEmpty()) + }) + + It("routes case-insensitively (lowercase path)", func() { + w := rawReq("GET", "/system/info/public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GET/POST /System/Ping", func() { + It("answers GET with a plain-text server name", func() { + w := rawReq("GET", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("answers POST identically", func() { + w := rawReq("POST", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) + }) + }) + + Describe("GET /QuickConnect/Enabled", func() { + It("reports QuickConnect disabled", func() { + w := rawReq("GET", "/QuickConnect/Enabled", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) + }) + }) +}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go new file mode 100644 index 000000000..722e828c5 --- /dev/null +++ b/server/jellyfin/images.go @@ -0,0 +1,170 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "strconv" + + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + _ "golang.org/x/image/webp" +) + +func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { + // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs + // under an elevated context to bypass the persistence visibility filter; playlist access is + // gated inside resolveArtworkID. + ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) + itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + + artID := api.resolveArtworkID(ctx, r, itemId) + reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + switch { + case errors.Is(err, context.Canceled): + return + case err != nil: + log.Warn(ctx, "Error retrieving artwork", "id", itemId, err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer reader.Close() + // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). + _, _ = io.Copy(w, reader) +} + +// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing +// album -> artist -> media file -> playlist. +func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { + if al, err := api.ds.Album(ctx).Get(itemId); err == nil { + return al.CoverArtID().String() + } + if ar, err := api.ds.Artist(ctx).Get(itemId); err == nil { + return ar.CoverArtID().String() + } + if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil { + return mf.CoverArtID().String() + } + if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { + // Playlist covers are user-scoped: serve a private one only for a public playlist or a + // token identifying its owner/an admin, so this public route can't probe others' covers. + u, ok := api.userFromToken(r) + if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { + return pl.CoverArtID().String() + } + } + return (model.ArtworkID{}).String() +} + +// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come +// from scanning. The body is always drained first (even on the not-implemented path) because +// Finamp writes it synchronously and sees a broken pipe if we respond before reading it. +func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + // Honor the same artwork-upload gate and size cap as the native endpoint. + u, _ := request.UserFrom(ctx) + if !conf.Server.EnableArtworkUpload && !u.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode + // the wire body (4/3 bigger), so the read cap allows for inflation. + limit := core.MaxImageUploadSize() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", + "playlistId", id, "limit", humanize.Bytes(uint64(limit)), err) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + imgBytes, err := decodeImageBody(body) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + if int64(len(imgBytes)) > limit { + log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize", + "playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit))) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + // Validate by decoding and derive the extension from the real format — clients lie in Content-Type. + _, format, err := image.DecodeConfig(bytes.NewReader(imgBytes)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + ext := "." + format + + if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. +func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + if err := api.playlists.RemoveImage(ctx, id); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients +// send raw bytes, so input already starting with an image magic number is passed through as-is. +func decodeImageBody(body []byte) ([]byte, error) { + if isImageMagic(body) { + return body, nil + } + trimmed := bytes.TrimSpace(body) + return base64.StdEncoding.DecodeString(string(trimmed)) +} + +func isImageMagic(b []byte) bool { + switch { + case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG + return true + case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG + return true + case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a) + return true + case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP + return true + default: + return false + } +} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go new file mode 100644 index 000000000..8099fcf94 --- /dev/null +++ b/server/jellyfin/images_test.go @@ -0,0 +1,394 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeArtwork struct { + artwork.Artwork + recvId string + recvCtx context.Context + data []byte +} + +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + f.recvId = id + f.recvCtx = ctx + data := f.data + if data == nil { + data = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(data)), time.Now(), nil +} + +func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("itemId", itemId) + rctx.URLParams.Add("type", "Primary") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + return w, r +} + +var _ = Describe("Images", func() { + It("streams album artwork", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(fa.recvId).To(ContainSubstring("a1")) + }) + + It("sniffs the Content-Type instead of hardcoding it", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + + png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) + fa := &fakeArtwork{data: png} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) + }) + + It("resolves a public playlist id to its cover artwork", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).To(ContainSubstring("pl1")) + }) + + It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + }) + + // This endpoint is public (no user in the request), so artwork must be resolved under an + // elevated context; otherwise a private playlist's cover fails its visibility filter and + // silently falls back to the placeholder. + It("resolves artwork under an elevated admin context", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + u, ok := request.UserFrom(fa.recvCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) +}) + +// Real image fixtures: postItemImage validates uploads by decoding them. +func pngBytes() []byte { + var b bytes.Buffer + Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) + return b.Bytes() +} + +func jpegBytes() []byte { + var b bytes.Buffer + Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +func gifBytes() []byte { + var b bytes.Buffer + Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). +func webpBytes() []byte { + b, err := base64.StdEncoding.DecodeString( + "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") + Expect(err).ToNot(HaveOccurred()) + return b +} + +var _ = Describe("postItemImage", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api = &Router{playlists: fp} + }) + + It("uploads a raw JPEG body and returns 204", func() { + body := jpegBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".jpeg")) + }) + + It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { + raw := pngBytes() + encoded := base64.StdEncoding.EncodeToString(raw) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded))) + r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(raw)) + Expect(fp.setImageExt).To(Equal(".png")) + }) + + It("returns 501 for a non-playlist item, draining the body first", func() { + fp.getByIDPls = nil + fp.getByIDErr = model.ErrNotFound + bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + Expect(bodyReader.Len()).To(Equal(0)) + }) + + It("returns 500 when the service fails", func() { + fp.setImageErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("accepts a raw WebP body", func() { + body := webpBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/webp") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".webp")) + }) + + It("accepts a raw GIF body", func() { + body := gifBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/gif") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".gif")) + }) + + It("rejects an oversized body with 400, like the native endpoint", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MaxImageUploadSize = "16" // 16 bytes + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") + }) + + It("applies the size limit to the decoded image, not the base64 body", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + // The raw image is exactly at the limit; its base64 form is 4/3 bigger. + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) + body := []byte(base64.StdEncoding.EncodeToString(img)) + Expect(len(body)).To(BeNumerically(">", len(img))) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(img)) + }) + + It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) + body := []byte(base64.StdEncoding.EncodeToString(img)) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects a body that is neither an image nor base64 with 400", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!")) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { + body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("forbids a non-admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("still allows an admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + }) +}) + +var _ = Describe("deleteItemImage", func() { + It("removes the playlist image and returns 204", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + }) + + It("returns 501 for a non-playlist item", func() { + fp := &fakePlaylists{getByIDErr: model.ErrNotFound} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + }) + + It("returns 500 when the service fails", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) +}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go new file mode 100644 index 000000000..43c8de12c --- /dev/null +++ b/server/jellyfin/items.go @@ -0,0 +1,840 @@ +package jellyfin + +import ( + "context" + "io" + "iter" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// notMissing excludes items whose backing files are all gone ("missing" is a real column on +// album, artist and media_file). +var notMissing = squirrel.Eq{"missing": false} + +// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match +// everything" and materialize the library, where the unfiltered path streams. +func searchTerm(p *req.Values) string { + return strings.TrimSpace(p.StringOr("searchterm", "")) +} + +func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { + res, err := api.queryItems(r.Context(), r) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a +// full-library response never builds every DTO at once. Exactly one of items/openCursor is set. +// +// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which +// writes to the DB on first use and would deadlock against an open reader, but before the first +// response byte, so a failed open is still a clean error rather than a truncated 200. +type itemsResult struct { + items []dto.BaseItemDto + openCursor func() (iter.Seq2[dto.BaseItemDto, error], error) + total int + start int +} + +func materialized(q dto.QueryResult) itemsResult { + return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex} +} + +func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult { + return itemsResult{openCursor: open, total: total, start: start} +} + +// chained streams several results back to back, skipping the first skip items — the unbounded +// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head. +func chained(results []itemsResult, total, skip int) itemsResult { + open := func() (iter.Seq2[dto.BaseItemDto, error], error) { + if len(results) == 0 { + return sliceItems(nil), nil + } + // Only the first opens eagerly (so the usual failure is still a clean error); the rest open as + // the stream reaches them, so only one cursor pins a DB connection at a time. + first, err := results[0].seq() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + n := 0 + emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return false + } + if n < skip { + n++ + continue + } + if !yield(it, nil) { + return false + } + } + return true + } + if !emit(first) { + return + } + for _, res := range results[1:] { + seq, err := res.seq() + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !emit(seq) { + return + } + } + }, nil + } + return streamed(open, total, skip) +} + +// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's +// underlying func type, so callers wrap repo.GetCursor for the named type to infer T. +func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) { + return func() (iter.Seq2[dto.BaseItemDto, error], error) { + cursor, err := openCursor() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + for row, err := range cursor { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !yield(toItem(row), nil) { + return + } + } + }, nil + } +} + +// seq returns the items as one sequence, opening the cursor if there is one. +func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) { + if ir.openCursor != nil { + return ir.openCursor() + } + return sliceItems(ir.items), nil +} + +// collect drains the result into a slice, for the merge that combines types before paginating. +func (ir itemsResult) collect() ([]dto.BaseItemDto, error) { + if ir.openCursor == nil { + return ir.items, nil + } + seq, err := ir.openCursor() + if err != nil { + return nil, err + } + var out []dto.BaseItemDto + for it, err := range seq { + if err != nil { + return nil, err + } + out = append(out, it) + } + return out, nil +} + +func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + return streamItemsEnvelope(w, items, res.total, res.start) + }) +} + +// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope. +func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, streamItemsArray) +} + +// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in +// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500. +func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, + write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { + sid := api.serverID(r.Context()) + seq, err := res.seq() + if err != nil { + api.internalError(w, r, err) + return + } + stamped := func(yield func(dto.BaseItemDto, error) bool) { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + it.ServerId = sid + if !yield(it, nil) { + return + } + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := write(w, stamped); err != nil { + log.Error(r.Context(), "Jellyfin API: error streaming response", err) + } +} + +// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead +// of a long positional parameter list. +type itemsQuery struct { + fields dto.Fields + ids []string + rawTypes string + types []string + search string + sortBy string + sortOrder string + offset int + limit int + favOnly bool + // parentId scopes the query. entityParent is the same id only when it names an entity (an artist + // for MusicAlbum, an album for Audio) rather than a library. + parentId string + entityParent string + isLibraryParent bool + scopeIDs []int + // artistId selects that artist's own discography; contributingOnly means albums they merely + // appear on (Jellyfin's "Featured On"), which must exclude that discography. + artistId string + contributingOnly bool + genreIds []string +} + +// parseItemsQuery also resolves the entity types (inferring them from the parent when +// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { + p := req.Params(r) + q := itemsQuery{ + fields: dto.ParseFields(p.StringOr("fields", "")), + ids: decodedQueryIDs(r, "ids"), + rawTypes: p.StringOr("includeitemtypes", ""), + search: searchTerm(p), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: dto.DecodeID(p.StringOr("parentid", "")), + // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. + genreIds: decodedQueryIDs(r, "genreids"), + } + // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + q.contributingOnly = albumArtistScope == "" && contributingScope != "" + + q.types = parseTypes(q.rawTypes) + q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + + // Recursive=false asks for direct children only, and no track is a library's direct child. + // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. + if q.isLibraryParent && !p.BoolOr("recursive", false) { + q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) + } + + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks + // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse + // its albums). + if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { + if q.parentId == playlistsFolderID { + // Browsing into the synthetic playlists folder lists the user's playlists. + q.types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { + q.types = []string{"Audio"} + } + } + // ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural + // parent entity, so there ParentId is only library scoping. + q.entityParent = q.parentId + if q.isLibraryParent || len(q.types) > 1 { + q.entityParent = "" + } + return q +} + +// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via +// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites +// screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { + q := api.parseItemsQuery(ctx, r) + switch { + // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. + case len(q.ids) > 0: + return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): + return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil + } + if repo, ok := api.playlistTracksRepo(ctx, q); ok { + return api.playlistTrackPage(repo, q.fields, q.offset, q.limit) + } + if q.search != "" { + q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) + } + if len(q.types) == 1 { + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, q.types[0], q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, q.types[0], opts, q) + } + return api.mergeTypes(ctx, q) +} + +// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a +// playlist with ParentId=&IncludeItemTypes=Audio, and routing that through listSongs would +// treat the playlist id as an album id and return nothing. +// +// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type +// dispatch: ParentId is usually an album or artist. +func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { + if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { + return nil, false + } + // Tracks enforces visibility. + repo, err := api.playlists.Tracks(ctx, q.parentId) + return repo, err == nil +} + +func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + window := 0 + if q.limit > 0 { + window = q.offset + q.limit + } + // A search can't stream, so the window is what each type materializes and StartIndex would drive + // it without bound. Only below the window are the merged rows the true order, hence the clip + // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. + if q.search != "" { + window = min(window, maxSearchLimit) + } + var results []itemsResult + total := 0 + for _, itemType := range q.types { + var opts model.QueryOptions + opts.Max = window + applySort(&opts, itemType, q.sortBy, q.sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, q) + if err != nil { + return itemsResult{}, err + } + results = append(results, res) + total += res.total + } + if q.limit == 0 { + // No cap above, so merging in memory would pull every row of every type. The merged page is + // just their rows in order minus the first offset — what chaining the cursors yields. + return chained(results, total, q.offset), nil + } + var items []dto.BaseItemDto + for _, res := range results { + typeItems, err := res.collect() + if err != nil { + return itemsResult{}, err + } + items = append(items, typeItems...) + } + if q.search != "" { + // Past the window the merged order isn't the true one, so drop it rather than serve another + // type's rows. The total is what's pageable overall, not this page, or a client paging on it + // would stop after the first page. + items = items[:min(window, len(items))] + total = min(total, maxSearchLimit) + } + return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + switch itemType { + case "Audio": + return api.listSongs(ctx, opts, q) + case "MusicArtist": + // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. + return api.listArtists(ctx, opts, q, model.RoleAlbumArtist) + case "MusicGenre": + return api.listGenres(ctx, opts) + case "Playlist": + return api.listPlaylists(ctx, opts, q) + default: // MusicAlbum + return api.listAlbums(ctx, opts, q) + } +} + +// firstNonEmpty returns the first non-empty string, or "". +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. +func firstDecodedID(s string) string { + if s == "" { + return "" + } + first, _, _ := strings.Cut(s, ",") + return dto.DecodeID(strings.TrimSpace(first)) +} + +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. +func decodedQueryIDs(r *http.Request, key string) []string { + return slice.Map(queryIDs(r, key), dto.DecodeID) +} + +// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to +// {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). +func parseTypes(types string) []string { + var recognized []string + for t := range strings.SplitSeq(types, ",") { + t = strings.TrimSpace(t) + switch t { + case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist": + recognized = append(recognized, t) + } + } + if len(recognized) == 0 { + return []string{"MusicAlbum"} + } + return recognized +} + +// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only +// (single-type queries push Offset/Max down to SQL instead). +func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { + if offset >= len(items) { + return []dto.BaseItemDto{} + } + items = items[offset:] + if limit > 0 && limit < len(items) { + items = items[:limit] + } + return items +} + +// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without +// the ceiling, Limit=999999 still materializes every match. +const ( + defaultSearchLimit = 100 + maxSearchLimit = 2000 +) + +// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an +// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size). +// +// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger +// offset+limit window: bounding that would truncate each type before the merged page is cut. +func clampLimit(limit, def, ceiling int) int { + if limit <= 0 { + return def + } + return min(limit, ceiling) +} + +// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the +// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is +// exact once matches end (and a growing lower bound before), so paging terminates at the last match. +func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { + fetch := opts + fetch.Max++ + rows, err := search(fetch) + if err != nil { + return nil, 0, err + } + total := opts.Offset + len(rows) + if len(rows) > opts.Max { + rows = rows[:opts.Max] + } + return rows, total, nil +} + +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + repo := api.ds.Album(ctx) + filters := squirrel.And{} + // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's + // albums"; contributingArtistIds means "albums they only appear on" (Featured On). + switch { + case q.contributingOnly && q.artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) + case firstNonEmpty(q.artistId, q.entityParent) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) + default: + filters = append(filters, notMissing) + } + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) + } + if q.favOnly { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) + + if q.search != "" { + albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { + return repo.Search(q.search, o) + }) + if err != nil { + return itemsResult{}, err + } + return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + return streamed(open, int(total), opts.Offset), nil +} + +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } + repo := api.ds.MediaFile(ctx) + filters := squirrel.And{} + // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. + switch { + case q.artistId != "": + filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) + case q.entityParent != "": + filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) + default: + filters = append(filters, notMissing) + } + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) + } + if q.favOnly { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) + + if q.search != "" { + mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { + return repo.Search(q.search, o) + }) + if err != nil { + return itemsResult{}, err + } + return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil + } + // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an + // explicit client SortBy still wins, since applySort already set opts.Sort. + if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(q.entityParent).Sort + } + // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { + return repo.GetCursor(opts) + }, toItem) + return streamed(open, int(total), opts.Offset), nil +} + +// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, +// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. +// genreIds isn't applied to search — a name lookup, like role (see below). +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { + repo := api.ds.Artist(ctx) + + // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a + // search scope (artists have no library_id column). A compound or join-based filter + // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build + // filters differently. Role isn't applied to search for the same reason — it's a name lookup. + if q.search != "" { + if len(q.scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} + } + artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { + return repo.Search(q.search, o) + }) + if err != nil { + return itemsResult{}, err + } + return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil + } + + if q.favOnly { + opts.Filters = filter.ArtistsByStarred().Filters + } else { + opts.Filters = notMissing + } + if len(q.genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} + } + opts = filter.ArtistsByRole(opts, role) + opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + open := streamCursor(func() (func(func(model.Artist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.ArtistToBaseItem) + return streamed(open, int(total), opts.Offset), nil +} + +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also +// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the +// length of the full list and paging is in-memory — nothing for a cursor to page over. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { + genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) + if err != nil { + return itemsResult{}, err + } + items := slice.Map(genres, dto.GenreToBaseItem) + return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil +} + +// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is +// enforced by playlistRepository, not scopeIDs. +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + if q.favOnly { + starred := squirrel.Eq{"starred": true} + if opts.Filters == nil { + opts.Filters = starred + } else { + opts.Filters = squirrel.And{opts.Filters, starred} + } + } + repo := api.ds.Playlist(ctx) + total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + if err != nil { + return itemsResult{}, err + } + open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.PlaylistToBaseItem) + return streamed(open, int(total), opts.Offset), nil +} + +// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, +// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access +// to their library, so an id can't probe content outside the user's libraries. +func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { + // The synthetic playlists folder must resolve by the id we advertised, not 404. + if id == playlistsFolderID { + return playlistsFolder(), true + } + u, _ := request.UserFrom(ctx) + // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this + // the home screen and library tabs 404. + if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { + for _, lib := range u.Libraries { + if lib.ID == libID { + return libraryView(lib), true + } + } + // Admin bypass: Libraries is empty but all access is granted, so fetch the real library. + if lib, err := api.ds.Library(ctx).Get(libID); err == nil { + return libraryView(*lib), true + } + } + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.AlbumToBaseItem(*al), true + } + if ar, err := api.ds.Artist(ctx).Get(id); err == nil { + // TODO: an artist spans multiple libraries (library_artist), so there's no single + // LibraryID to gate here; artist access relies on list-time scoping and persistence. + return dto.ArtistToBaseItem(*ar), true + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.SongToBaseItem(*mf, fields), true + } + // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. + if pl, err := api.playlists.Get(ctx, id); err == nil { + return dto.PlaylistToBaseItem(*pl), true + } + return dto.BaseItemDto{}, false +} + +// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id. +func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile { + songs := make(map[string]model.MediaFile, len(ids)) + // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. + for chunk := range slice.CollectChunks(slices.Values(ids), 500) { + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) + if err != nil { + log.Error(ctx, "Jellyfin API: error fetching songs by id", err) + continue + } + for _, mf := range mfs { + songs[mf.ID] = mf + } + } + return songs +} + +// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. +// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored +// queue items against its stored (truncated) ids. +func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { + u, _ := request.UserFrom(ctx) + fullIDs := api.resolveItemIDs(ctx, ids) + songs := api.songsByIDs(ctx, fullIDs) + var items []dto.BaseItemDto + for i, id := range fullIDs { + var item dto.BaseItemDto + if mf, ok := songs[id]; ok { + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + item = dto.SongToBaseItem(mf, fields) + } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { + continue + } + if id != ids[i] { + item.Id = dto.EncodeID(ids[i]) + } + items = append(items, item) + } + return result(items, len(items), 0) +} + +func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { + api.ok(w, r, item) + return + } + http.Error(w, "Not Found", http.StatusNotFound) +} + +// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from +// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. +func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + if err := api.playlists.Delete(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for +// /Items/Latest, and why it writes directly instead of going through api.ok. +func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := filter.AlbumsByNewest() + opts.Max = req.Params(r).IntOr("limit", 20) + opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + repo := api.ds.Album(ctx) + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + api.writeItemsArray(w, r, streamed(open, 0, 0)) +} + +func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { + if items == nil { + items = []dto.BaseItemDto{} + } + return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} +} + +// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the +// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); +// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), +// not passed through raw where it could produce an invalid ORDER BY. +func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + for key := range strings.SplitSeq(sortBy, ",") { + if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { + opts.Sort = col + break + } + } + if strings.EqualFold(order, "Descending") { + opts.Order = "desc" + } +} + +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type. Each repository maps +// logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no +// "random"). +var sortColumnsByType = map[string]map[string]string{ + "Audio": { + "sortname": "title", "name": "title", + "album": "album", + // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's + // "album" sort key is disc+track order within an album, so map both to it. + "indexnumber": "album", + "parentindexnumber": "album", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "random": "random", + // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. + "premieredate": "year", + "productionyear": "year", + }, + "MusicArtist": { + "sortname": "name", "name": "name", + "albumcount": "album_count", + "songcount": "song_count", + "datecreated": "created_at", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + }, + "MusicAlbum": { + "sortname": "name", "name": "name", "album": "name", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "random": "random", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "premieredate": "max_year", "productionyear": "max_year", + }, + "MusicGenre": { + "sortname": "name", "name": "name", + }, + "Playlist": { + "sortname": "name", "name": "name", + "datecreated": "created_at", + }, +} + +// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for +// itemType, reporting false when it isn't recognized for that type. +func sortColumn(itemType, sortBy string) (string, bool) { + col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)] + return col, ok +} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go new file mode 100644 index 000000000..401145461 --- /dev/null +++ b/server/jellyfin/items_test.go @@ -0,0 +1,899 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// withChiURLParam simulates chi's routing having captured a path parameter, since these +// tests call handlers directly instead of going through the full router. +func withChiURLParam(r *http.Request, key, value string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add(key, value) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +var _ = Describe("Items", func() { + var api *Router + var ds *tests.MockDataStore + var fp *fakePlaylists + // alice has access to library 1 only; used by tests that don't care about scoping. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + ctxUserWithLibraries := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + BeforeEach(func() { + ds = &tests.MockDataStore{} + fp = &fakePlaylists{} + api = &Router{ds: ds, playlists: fp} + }) + + Describe("getItems", func() { + It("lists albums when IncludeItemTypes=MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("lists an album's songs when ParentId is an album and type is Audio", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("pages a playlist parent's tracks in the query, not in memory", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + + It("falls through to the type dispatch when ParentId is not a playlist", func() { + fp.getErr = model.ErrNotFound + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + // Recursive=false asks for direct children only. Finamp's sync probes a library this way + // looking for tracks outside any album; answering with every track streams the whole library. + Describe("Recursive=false", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + }) + + It("returns no songs for a library parent, as tracks are never its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(BeZero()) + }) + + It("drops only Audio from a multi-type library query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("still lists albums for a library parent, as they are its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("still lists an album's tracks, as they are its direct children", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("keeps returning every song when no parent scopes the query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + // Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as + // `recursive ?? false`, so an omitted Recursive is a non-recursive request. + It("treats an omitted Recursive as false, like Jellyfin", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + }) + + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("json_tree")) + }) + + It("lists artists when IncludeItemTypes=MusicArtist", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("lists genres when IncludeItemTypes=MusicGenre", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("lists playlists when IncludeItemTypes=Playlist", func() { + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Playlist")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("merges results from every requested type in IncludeItemTypes", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("Audio", "MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("merges favorite songs, albums, and playlists", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(3)) + types := []string{res.Items[0].Type, res.Items[1].Type} + types = append(types, res.Items[2].Type) + Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist")) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + playlistSQL, _, err := playlistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(playlistSQL).To(ContainSubstring("starred")) + }) + + It("applies StartIndex/Limit to the merged multi-type result set", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(4)) + Expect(res.StartIndex).To(Equal(1)) + }) + + It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // The merged window is [1, 3): each type needs at most its first 3 rows, not the table. + Expect(mfRepo.Options.Max).To(Equal(3)) + Expect(albumRepo.Options.Max).To(Equal(3)) + }) + + It("applies a starred filter when Filters=IsFavorite", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("caps a search the client left unbounded", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + }) + + It("honors an explicit search Limit up to the ceiling", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(501)) + }) + + It("clamps a search Limit that would materialize the library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(albumRepo.SearchQuery).To(BeEmpty()) + }) + + It("reports a multi-type search total past the page, so clients keep paging", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(10)) + Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) + }) + + It("bounds the multi-type search window however large StartIndex is", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // Without the bound this asks each type for ~500001 rows. + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { + // Bounding the per-type window is what keeps StartIndex from driving it without limit, and + // past that window the merged order is no longer the true one. + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(maxSearchLimit)) + }) + + It("serves the last page below the ceiling in full", func() { + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + // Clipped to the window, and still the real row at that index — not the album behind it. + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + }) + + It("bounds an unbounded multi-type search to the default in total, not per type", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(defaultSearchLimit)) + }) + + It("pages an unbounded multi-type search past the default without dropping matches", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).ToNot(BeEmpty()) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+50].ID))) + }) + + It("reports a search total beyond the fetched page instead of the page length", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ + {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.TotalRecordCount).To(Equal(3)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Offset).To(Equal(5)) + Expect(albumRepo.Options.Max).To(Equal(10)) + }) + + Describe("Ids batch-fetch", func() { + // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=; without + // this, queryItems ignored Ids and returned the default type-dispatched list instead. + It("returns exactly the requested item when Ids has a single id", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Name).To(Equal("Song")) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("returns items of different types for a lowercase ids param with multiple ids", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + ids := []string{res.Items[0].Id, res.Items[1].Id} + Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1"))) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("MusicAlbum", "Audio")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("resolves song ids with one batched IN query, not a Get per id", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.id IN")) + Expect(args).To(ConsistOf("s1", "s2")) + }) + + It("omits an id in a library the user can't access, without erroring the whole batch", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("sorting", func() { + It("maps SortBy=PlayCount to the play_count column", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("play_count")) + }) + + It("maps SortBy=DatePlayed to the play_date column", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("play_date")) + }) + + It("uses the first recognized key in a comma-separated SortBy list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("recently_added")) + }) + + It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("title")) + }) + + It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("album")) + }) + + It("leaves Sort at the repo default when no SortBy key is recognized", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("")) + }) + }) + + Describe("library scoping", func() { + It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a MusicArtist listing to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("json_tree")) // not treated as an artist-parent filter + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(2)) + }) + + It("does not let ParentId= scope results to that library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + // Falls back to treating "99" as an (empty-matching) artist-parent id... + Expect(sql).To(ContainSubstring("json_tree")) + // ...while still scoping to the user's own accessible libraries. + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("does not restrict a default MusicAlbum listing for an admin user", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyLibraryFilter([]) is a no-op: no library_id restriction is added. + if albumRepo.Options.Filters == nil { + return + } + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_id")) + }) + }) + }) + + Describe("getItem", func() { + It("returns an album by id", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an album in a library the user can't access", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a song in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns an album to an admin even when it's outside their (empty) Libraries", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + }) + + // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the + // library node before it can load the home screen or any library tab. + It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1, Name: "Music Library"}} + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + Expect(item.CollectionType).To(Equal("music")) + Expect(item.IsFolder).To(BeTrue()) + }) + + It("does not resolve a library-view id the user has no access to", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it + // 404s with "Could not fetch BaseItemDto from server." + It("resolves a playlist id via the playlists service", func() { + fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("p1"))) + Expect(item.Name).To(Equal("My Mix")) + Expect(item.Type).To(Equal("Playlist")) + }) + + It("returns 404 for a non-owned or absent playlist id", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { + ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + }) + }) + + Describe("getLatest", func() { + It("returns a bare array of the newest albums", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var items []dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) + Expect(items).To(HaveLen(1)) + Expect(items[0].Id).To(Equal(dto.EncodeID("a1"))) + }) + + It("scopes to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + }) +}) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go new file mode 100644 index 000000000..aab9628a0 --- /dev/null +++ b/server/jellyfin/jellyfin_suite_test.go @@ -0,0 +1,25 @@ +package jellyfin + +import ( + "net/http" + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinApi(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API Suite") +} + +// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call +// handlers directly (with withChiURLParam for path params) instead of routing, so without this the +// case-insensitive query folding real requests get would be skipped and PascalCase params dropped. +func invoke(h http.HandlerFunc, w http.ResponseWriter, r *http.Request) { + normalizeQueryKeys(h).ServeHTTP(w, r) +} diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go new file mode 100644 index 000000000..2e486c36f --- /dev/null +++ b/server/jellyfin/library.go @@ -0,0 +1,44 @@ +package jellyfin + +import ( + "context" + "strconv" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty +// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters. +func accessibleLibraryIDs(ctx context.Context) []int { + u, _ := request.UserFrom(ctx) + return u.Libraries.IDs() +} + +// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an +// entity id (artist/album). It's treated as a library only when the user has access; otherwise +// isLibraryParent is false and callers fall through to entity-id handling. +func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) { + if parentId != "" { + if id, err := strconv.Atoi(parentId); err == nil { + if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) { + return []int{id}, true + } + } + } + return accessibleLibraryIDs(ctx), false +} + +// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. +// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. +func libraryView(lib model.Library) dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(strconv.Itoa(lib.ID)), + Name: lib.Name, + Type: "CollectionFolder", + CollectionType: "music", + IsFolder: true, + BackdropImageTags: []string{}, + } +} diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go new file mode 100644 index 000000000..840143ac8 --- /dev/null +++ b/server/jellyfin/middlewares.go @@ -0,0 +1,220 @@ +package jellyfin + +import ( + "net" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +// throttleStreams bounds how many collection responses stream concurrently, so they can't take every +// connection in the shared DB pool: each holds a cursor, and its connection, for the whole +// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it. +// +// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire +// response to release its token early, which is right for artwork but would undo the streaming here. +// chi's panics on a non-positive limit, hence the guard. +func throttleStreams(limit int) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) +} + +// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the +// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param +// segments too, which is safe because every id the API emits — user ids included — is lowercase hex +// (dto.EncodeID). +func caseInsensitivePaths(r chi.Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path. + if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = strings.ToLower(rctx.RoutePath) + } else { + req.URL.Path = strings.ToLower(req.URL.Path) + } + r.ServeHTTP(w, req) + }) +} + +// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params +// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, +// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one +// client's filters, sort and paging. Only keys are folded — values keep their case. The original +// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing. +func normalizeQueryKeys(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + folded := make(url.Values, len(q)) + changed := false + for k, vs := range q { + lk := strings.ToLower(k) + // Append, don't assign: two casings of the same key must merge, not overwrite. + folded[lk] = append(folded[lk], vs...) + if lk != k { + changed = true + } + } + if changed { + r2 := *r + u := *r.URL + u.RawQuery = folded.Encode() + r2.URL = &u + r = &r2 + } + next.ServeHTTP(w, r) + }) +} + +type mediaBrowserAuth struct { + Client, Device, DeviceId, Version, Token string +} + +var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) + +// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g. +// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. +// The recommended Authorization header is preferred, but only when it actually carries +// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client +// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk) +// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField +// leaves a raw value untouched. +func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth { + if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok { + return a + } + a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization")) + return a +} + +// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the +// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts). +func parseAuthHeader(h string) (mediaBrowserAuth, bool) { + var a mediaBrowserAuth + scheme, params, found := strings.Cut(h, " ") + if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) { + return a, false + } + for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) { + switch m[1] { + case "Client": + a.Client = unescapeField(m[2]) + case "Device": + a.Device = unescapeField(m[2]) + case "DeviceId": + a.DeviceId = unescapeField(m[2]) + case "Version": + a.Version = unescapeField(m[2]) + case "Token": + a.Token = unescapeField(m[2]) + } + } + return a, true +} + +// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't +// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not +// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space. +func unescapeField(v string) string { + if decoded, err := url.PathUnescape(v); err == nil { + return decoded + } + return v +} + +// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings +// deprecated by Jellyfin but still sent by clients. +func tokenFromRequest(r *http.Request) string { + if t := parseMediaBrowserAuth(r).Token; t != "" { + return t + } + if t := r.Header.Get("X-Emby-Token"); t != "" { + return t + } + if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { + return t + } + // api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't + // merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=). + if t := r.URL.Query().Get("api_key"); t != "" { + return t + } + return r.URL.Query().Get("apikey") +} + +// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token +// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +func (api *Router) userFromToken(r *http.Request) (model.User, bool) { + token := tokenFromRequest(r) + if token == "" { + return model.User{}, false + } + claims, err := auth.Validate(token) + if err != nil || claims.Subject == "" { + return model.User{}, false + } + usr, err := api.ds.User(r.Context()).FindByUsername(claims.Subject) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) + return model.User{}, false + } + return *usr, true +} + +func (api *Router) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + usr, ok := api.userFromToken(r) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := request.WithUser(r.Context(), usr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring +// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic), +// so it's used directly as the player id and reports from the same install share a player/scrobbling +// session. +func (api *Router) withPlayer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if api.players == nil { // fail open when players isn't wired (e.g. in unit tests) + next.ServeHTTP(w, r) + return + } + ctx := r.Context() + a := parseMediaBrowserAuth(r) + // Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g. + // the /socket handshake that authenticates via ?api_key= only). Otherwise Register would + // create a junk player with an empty name (" []"). + if a.Client == "" && a.DeviceId == "" { + next.ServeHTTP(w, r) + return + } + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) + if err != nil { + // Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers + // degrade gracefully. + log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) + next.ServeHTTP(w, r) + return + } + ctx = request.WithPlayer(ctx, *player) + // Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override. + if trc != nil { + ctx = request.WithTranscoding(ctx, *trc) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go new file mode 100644 index 000000000..f3aa65d6f --- /dev/null +++ b/server/jellyfin/middlewares_test.go @@ -0,0 +1,382 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authenticate middleware", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + tokenFor := func(name string) string { + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name}) + Expect(err).ToNot(HaveOccurred()) + return t + } + + It("passes with a valid X-Emby-Token and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", tokenFor("alice")) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("rejects a missing token with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a garbage token with 401 and does not call next", func() { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", "not-a-jwt") + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + It("rejects a valid token whose subject user does not exist with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", t) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var players *fakePlayers + + BeforeEach(func() { + players = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: players} + }) + + callWith := func() (model.Player, model.Transcoding, bool) { + var gotPlayer model.Player + var gotTrc model.Transcoding + var hasTrc bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, _ = request.PlayerFrom(r.Context()) + gotTrc, hasTrc = request.TranscodingFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + api.withPlayer(next).ServeHTTP(w, r) + return gotPlayer, gotTrc, hasTrc + } + + It("injects the registered player into the context", func() { + player, _, hasTrc := callWith() + Expect(player.ID).To(Equal("dev1")) + Expect(hasTrc).To(BeFalse()) + }) + + It("injects the player's server-forced transcoding into the context", func() { + players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + _, trc, hasTrc := callWith() + Expect(hasTrc).To(BeTrue()) + Expect(trc.TargetFormat).To(Equal("opus")) + }) +}) + +var _ = Describe("tokenFromRequest", func() { + It("accepts the recommended Authorization: MediaBrowser scheme", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("prefers the Authorization scheme token over deprecated token headers", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`) + r.Header.Set("X-Emby-Token", "legacy-token") + Expect(tokenFromRequest(r)).To(Equal("scheme-token")) + }) + + It("accepts the lowercase api_key query param", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) + Expect(got).To(Equal("tok123")) + }) +}) + +var _ = Describe("parseMediaBrowserAuth", func() { + authFor := func(header string) mediaBrowserAuth { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-Emby-Authorization", header) + return parseMediaBrowserAuth(r) + } + + It("reads Finamp's raw (unencoded) field values", func() { + a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + Expect(a.DeviceId).To(Equal("dev1")) + }) + + It("percent-decodes Jellify's URL-encoded field values", func() { + a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Jellify")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + }) + + It("keeps a literal '%' that isn't valid percent-encoding", func() { + a := authFor(`MediaBrowser Client="100% Player", Device="d"`) + Expect(a.Client).To(Equal("100% Player")) + }) + + It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("New")) + Expect(a.DeviceId).To(Equal("dev-new")) + }) + + It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() { + // A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must + // still be honored. + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Digest username="proxy", realm="site"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Token).To(Equal("tok")) + }) + + It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Custom Token="not-for-us"`) + Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty()) + }) + + It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() { + a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`) + Expect(a.Client).To(Equal("OldClient")) + Expect(a.Token).To(Equal("tok")) + }) + + It("matches the scheme case-insensitively (HTTP auth schemes are)", func() { + a := authFor(`mediabrowser Token="tok"`) + Expect(a.Token).To(Equal("tok")) + }) +}) + +var _ = Describe("normalizeQueryKeys", func() { + // keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for + // the given (lowercase) key — i.e. what a case-insensitive read would find. + keyFor := func(rawQuery, key string) string { + r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r) + return got + } + + It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() { + Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc")) + Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc")) + }) + + It("leaves values untouched", func() { + Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio")) + }) + + It("passes already-lowercase keys through unchanged", func() { + Expect(keyFor("container=mp3", "container")).To(Equal("mp3")) + }) + + It("merges values when two keys fold to the same name instead of dropping one", func() { + r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil) + var got []string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r) + Expect(got).To(ConsistOf("aaa", "bbb")) + }) +}) + +var _ = Describe("throttleStreams", func() { + // serve fires n concurrent requests through the middleware and reports the highest number that + // were ever inside the handler at once. + serve := func(limit, n int) int32 { + var inFlight, peak int32 + release := make(chan struct{}) + h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cur := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&peak) + if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) { + break + } + } + <-release // hold the slot until every request has had a chance to enter + atomic.AddInt32(&inFlight, -1) + })) + + var wg sync.WaitGroup + for range n { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + // Give the admitted requests time to pile up before letting them finish. + time.Sleep(100 * time.Millisecond) + close(release) + wg.Wait() + return atomic.LoadInt32(&peak) + } + + It("admits no more than the limit at once", func() { + Expect(serve(2, 8)).To(Equal(int32(2))) + }) + + It("queues the excess rather than rejecting it", func() { + // All 8 still complete — they wait for a slot instead of getting a 429. + var served int32 + release := make(chan struct{}) + h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + atomic.AddInt32(&served, 1) + })) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + close(release) + wg.Wait() + Expect(served).To(Equal(int32(8))) + }) + + // chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not + // crash the server at startup. + It("is disabled, not panicking, when the limit is zero", func() { + Expect(func() { serve(0, 4) }).ToNot(Panic()) + Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) + }) +}) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID, gotContainer string + + BeforeEach(func() { + gotID, gotContainer = "", "" + r := chi.NewRouter() + // Routes are registered lowercase, mirroring the real router. + r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + // A second route reusing the "bar" segment name at a different position. + r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + serve := func(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + return w + } + + It("routes a mixed-case request to its lowercase-registered route", func() { + Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK)) + }) + + It("routes both routes that share a segment name, regardless of casing", func() { + Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK)) + Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK)) + }) + + It("lowercases the mixed literal.extension segment so the route and container match", func() { + w := serve("/Audio/abc/STREAM.MP3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() { + serve("/foo/DEADBEEF/bar") + Expect(gotID).To(Equal("deadbeef")) + }) + + It("normalizes the RoutePath branch when mounted under a parent", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", handler) + w := httptest.NewRecorder() + parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go new file mode 100644 index 000000000..084ff2975 --- /dev/null +++ b/server/jellyfin/playlists.go @@ -0,0 +1,288 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve +// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal +// can't collide with real ids (those are hashes). +const playlistsFolderID = "playlists" + +// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be +// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. +func playlistsFolder() dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(playlistsFolderID), + Name: "Playlists", + Type: "ManualPlaylistsFolder", + CollectionType: "playlists", + IsFolder: true, + } +} + +// playlistError maps core/playlists write errors to HTTP status: ownership -> 403, missing/invisible +// -> 404 (never revealing another user's private playlist), else -> 500. +func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, model.ErrNotAuthorized): + http.Error(w, "Forbidden", http.StatusForbidden) + case errors.Is(err, model.ErrNotFound): + http.Error(w, "Not Found", http.StatusNotFound) + default: + api.internalError(w, r, err) + } +} + +type createPlaylistRequest struct { + Name string `json:"Name"` + Ids []string `json:"Ids"` + MediaType string `json:"MediaType"` +} + +// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to +// replace an existing one), owned by the authenticated user. +func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { + var body createPlaylistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID)) + id, err := api.playlists.Create(r.Context(), "", body.Name, ids) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)}) +} + +// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means +// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear). +type updatePlaylistRequest struct { + Name *string `json:"Name"` + Ids *[]string `json:"Ids"` + IsPublic *bool `json:"IsPublic"` +} + +func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + var body updatePlaylistRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // A present Ids replaces the track list. An empty list must clear it explicitly, since Create + // can't persist an empty track list (the repository skips track writes when the list is empty). + if body.Ids != nil { + if len(*body.Ids) == 0 { + if err := api.clearPlaylist(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + } else { + ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { + api.playlistError(w, r, err) + return + } + } + } + if body.Ids == nil || body.Name != nil || body.IsPublic != nil { + if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil { + api.playlistError(w, r, err) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership. +func (api *Router) clearPlaylist(ctx context.Context, id string) error { + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + return err + } + if len(pls.Tracks) == 0 { + return nil + } + entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID }) + return api.playlists.RemoveTracks(ctx, id, entryIDs) +} + +// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the +// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing +// tracks, and counts the same set, like GetWithTracks. +func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { + total, err := repo.CountAll(model.QueryOptions{Filters: notMissing}) + if err != nil { + return itemsResult{}, err + } + opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing} + open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) { + return repo.GetCursor(opts) + }, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + return streamed(open, int(total), offset), nil +} + +// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the +// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via +// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain +// individually removable. +func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { + item := dto.SongToBaseItem(t.MediaFile, fields) + item.PlaylistItemId = dto.EncodeID(t.ID) + return item +} + +// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the +// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't +// be probed. +func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + repo, err := api.playlists.Tracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // PlaylistInfo carries every track id, so this can't be paged — but it needs no track data. + trackIDs, err := repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Filters: notMissing}) + if err != nil { + api.internalError(w, r, err) + return + } + itemIds := slice.Map(trackIDs, dto.EncodeID) + api.ok(w, r, dto.PlaylistInfo{ + OpenAccess: pls.Public, + Shares: []dto.PlaylistUserPermissions{}, + ItemIds: itemIds, + }) +} + +// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a +// playlist id can't probe for private playlists. +func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + repo, err := api.playlists.Tracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + p := req.Params(r) + fields := dto.ParseFields(p.StringOr("fields", "")) + res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single +// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns +// the flattened, non-empty ids across both forms. +func queryIDs(r *http.Request, key string) []string { + var ids []string + for _, v := range r.URL.Query()[key] { + for id := range strings.SplitSeq(v, ",") { + if id != "" { + ids = append(ids, id) + } + } + } + return ids +} + +// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when +// building a playlist into their track ids, in order, since core/playlists only understands media +// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only +// the rest pays per-id container probes. +func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string { + songs := api.songsByIDs(ctx, ids) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if _, ok := songs[id]; ok { + out = append(out, id) // already a song + } else if _, err := api.ds.Album(ctx).Get(id); err == nil { + out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...) + } else if _, err := api.ds.Artist(ctx).Get(id); err == nil { + out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...) + } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { + out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) + } else { + out = append(out, id) // unknown id — pass through unchanged + } + } + return out +} + +func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string { + mfs, err := api.ds.MediaFile(ctx).GetAll(opts) + if err != nil { + log.Error(ctx, "Jellyfin: error expanding container to tracks", err) + return nil + } + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) +} + +// addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). +// AddTracks enforces ownership; any error maps to 404. +func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + ids := api.expandContainerIDs(ctx, slice.Map(queryIDs(r, "ids"), dto.DecodeID)) + if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media +// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces +// ownership; any error maps to 404. +func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "playlistId")) + ids := slice.Map(queryIDs(r, "entryids"), dto.DecodeID) + if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// getPlaylistUsers and getPlaylistUser answer client probes (e.g. Finamp) made before allowing +// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still +// enforced by AddTracks/RemoveTracks. +func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) +} + +func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { + userId := chi.URLParam(r, "userId") + api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true}) +} diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go new file mode 100644 index 000000000..7770cb6db --- /dev/null +++ b/server/jellyfin/playlists_test.go @@ -0,0 +1,464 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so +// unimplemented methods aren't needed here; only the ones this test exercises are overridden. +type fakePlaylists struct { + playlists.Playlists + + createdName string + createdIds []string + createErr error + + getPls *model.Playlist + getErr error + tracksRepo *tests.MockPlaylistTrackRepo + + getByIDPls *model.Playlist + getByIDErr error + + addPlaylistID string + addIds []string + addErr error + + removePlaylistID string + removeIds []string + removeErr error + + setImagePlaylistID string + setImageBytes []byte + setImageExt string + setImageErr error + + removeImagePlaylistID string + removeImageErr error + + deletePlaylistID string + deleteErr error +} + +func (f *fakePlaylists) Delete(_ context.Context, id string) error { + f.deletePlaylistID = id + return f.deleteErr +} + +func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) { + f.createdName = name + f.createdIds = ids + if f.createErr != nil { + return "", f.createErr + } + return "pl-new", nil +} + +// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real +// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't +// care about playlists leave it unconfigured. +func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) { + if f.getByIDErr != nil { + return nil, f.getByIDErr + } + if f.getByIDPls == nil { + return nil, model.ErrNotFound + } + return f.getByIDPls, nil +} + +func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound // mirror the real repo: never (nil, nil) + } + return f.getPls, nil +} + +// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert +// what was pushed down to the query. +func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound + } + f.tracksRepo = &tests.MockPlaylistTrackRepo{} + f.tracksRepo.SetData(f.getPls.Tracks) + return f.tracksRepo, nil +} + +func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { + f.addPlaylistID = playlistID + f.addIds = ids + return len(ids), f.addErr +} + +func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error { + f.removePlaylistID = playlistID + f.removeIds = trackIds + return f.removeErr +} + +func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error { + f.setImagePlaylistID = playlistID + f.setImageExt = ext + if reader != nil { + f.setImageBytes, _ = io.ReadAll(reader) + } + return f.setImageErr +} + +func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error { + f.removeImagePlaylistID = playlistID + return f.removeImageErr +} + +var _ = Describe("Playlists", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{} + api = &Router{ds: &tests.MockDataStore{}, playlists: fp} + }) + + Describe("createPlaylist", func() { + It("creates a playlist and returns its id", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res map[string]string + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new"))) + Expect(fp.createdName).To(Equal("Mix")) + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 400 on an invalid JSON body", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 500 when the service fails", func() { + fp.createErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("getPlaylistItems", func() { + It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(2)) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) + }) + + It("pages with StartIndex/Limit, pushing them down to the query", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylistItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("container id expansion", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + api = &Router{ds: ds, playlists: fp} + }) + + createWith := func(id string) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + WithContext(ctx) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("passes a bare song id through unchanged", func() { + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}}) + createWith("s1") + Expect(fp.createdIds).To(Equal([]string{"s1"})) + }) + + It("expands an album id into its songs, filtered by album", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + }) + createWith("al1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters)) + }) + + It("expands an artist id into its songs", func() { + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}}) + createWith("ar1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters)) + }) + + It("expands a playlist id into its tracks' media file ids", func() { + fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"}, + }} + createWith("pl9") + Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + }) + }) + + Describe("getPlaylist", func() { + It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { + pls := &model.Playlist{ + ID: "pl1", + Public: true, + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }, + } + fp.getPls, fp.getByIDPls = pls, pls + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistInfo + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.OpenAccess).To(BeTrue()) + Expect(res.Shares).To(BeEmpty()) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")})) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("deleteItem", func() { + deleteReq := func(id string) *http.Request { + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background()) + return withChiURLParam(r, "itemId", dto.EncodeID(id)) + } + + It("deletes the playlist and returns 204", func() { + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.deletePlaylistID).To(Equal("pl1")) + }) + + It("returns 403 when the user doesn't own the playlist", func() { + fp.deleteErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("returns 404 for a missing playlist or non-playlist id", func() { + fp.deleteErr = model.ErrNotFound + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("al1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 on an unexpected error", func() { + fp.deleteErr = errors.New("boom") + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("addToPlaylist", func() { + It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.addErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(BeEmpty()) + }) + }) + + Describe("removeFromPlaylist", func() { + It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.removeErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(BeEmpty()) + }) + }) + + Describe("getPlaylistUsers", func() { + It("returns the current user with CanEdit true", func() { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res []dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}})) + }) + }) + + Describe("getPlaylistUser", func() { + It("returns CanEdit true for the requested user", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background()) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("playlistId", "pl1") + rctx.URLParams.Add("userId", "u1") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + api.getPlaylistUser(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true})) + }) + }) +}) diff --git a/server/jellyfin/response.go b/server/jellyfin/response.go new file mode 100644 index 000000000..f4b96eda3 --- /dev/null +++ b/server/jellyfin/response.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "iter" + "strconv" + + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// streamItemsEnvelope writes a QueryResult, byte-identical to json.NewEncoder(w).Encode(q). +// +// A mid-stream error aborts without closing the envelope: the 200 is already committed, so a +// truncated-but-valid body would let a sync client treat the short list as the whole library and +// prune local tracks. Malformed JSON forces its parser to fail instead. Callers open the cursor +// before the first byte, so this only fires on a rare mid-iteration failure. +func streamItemsEnvelope(w io.Writer, items iter.Seq2[dto.BaseItemDto, error], total, start int) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString(`{"Items":[`) + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString(`],"TotalRecordCount":`) + _, _ = bw.WriteString(strconv.Itoa(total)) + _, _ = bw.WriteString(`,"StartIndex":`) + _, _ = bw.WriteString(strconv.Itoa(start)) + _, _ = bw.WriteString("}\n") + return bw.Flush() +} + +// streamItemsArray writes a bare JSON array — the shape /Items/Latest returns, with no envelope. +func streamItemsArray(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString("[") + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString("]\n") + return bw.Flush() +} + +// encodeItems writes items comma-separated. Unlike the fixed envelope writes, these are checked: +// bufio surfaces a latched write error here once a flush fails, and a client that has gone away must +// abandon the scan rather than pull the rest of the library through the cursor — which would hold its +// pooled DB connection and stream slot for a response nobody is reading. +func encodeItems(bw *bufio.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + // One reused buffer+encoder, so per-item JSON doesn't allocate. Encode HTML-escapes like + // json.Marshal, and appends a newline that's dropped below. + var itemBuf bytes.Buffer + enc := json.NewEncoder(&itemBuf) + first := true + for item, err := range items { + if err != nil { + return err + } + if !first { + if _, err := bw.WriteString(","); err != nil { + return err + } + } + first = false + itemBuf.Reset() + if err := enc.Encode(item); err != nil { + return err + } + b := itemBuf.Bytes() + if _, err := bw.Write(b[:len(b)-1]); err != nil { + return err + } + } + return nil +} + +func sliceItems(items []dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for i := range items { + if !yield(items[i], nil) { + return + } + } + } +} diff --git a/server/jellyfin/response_test.go b/server/jellyfin/response_test.go new file mode 100644 index 000000000..c32c566fc --- /dev/null +++ b/server/jellyfin/response_test.go @@ -0,0 +1,116 @@ +package jellyfin + +import ( + "bytes" + "encoding/json" + "errors" + "iter" + "strings" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// deadWriter stands in for a client that went away mid-response. +type deadWriter struct{} + +func (deadWriter) Write([]byte) (int, error) { return 0, errors.New("connection reset by peer") } + +var _ = Describe("streaming a materialized QueryResult", func() { + // The materialized path (api.ok -> writeItems -> sliceItems) must stay byte-for-byte identical to + // what json.Encoder.Encode produced before, so no client sees a different response. + assertIdenticalToEncoder := func(q dto.QueryResult) { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, sliceItems(q.Items), q.TotalRecordCount, q.StartIndex)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(q)).To(Succeed()) + + Expect(got.String()).To(Equal(want.String())) + } + + It("encodes an empty item list", func() { + assertIdenticalToEncoder(dto.QueryResult{Items: []dto.BaseItemDto{}}) + }) + + It("encodes a single item", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{{Id: "a", Name: "One"}}, + TotalRecordCount: 1, + }) + }) + + It("encodes multiple items, honoring HTML escaping and StartIndex", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{ + {Id: "a", Name: "One"}, + {Id: "b", Name: "Two & "}, + }, + TotalRecordCount: 500, + StartIndex: 100, + }) + }) +}) + +var _ = Describe("streamItemsEnvelope", func() { + seqOf := func(items ...dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } + } + + It("produces the same bytes as encoding an equivalent QueryResult", func() { + items := []dto.BaseItemDto{{Id: "a", Name: "One"}, {Id: "b", Name: "Two & "}} + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(items...), 500, 100)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(dto.QueryResult{Items: items, TotalRecordCount: 500, StartIndex: 100})).To(Succeed()) + Expect(got.String()).To(Equal(want.String())) + }) + + It("emits an empty array (not null) for a sequence that yields nothing", func() { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(), 0, 0)).To(Succeed()) + Expect(got.String()).To(Equal("{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}\n")) + }) + + // A client that goes away must not keep the source (a DB cursor, holding its pooled connection + // and a stream slot) running to the end of the library. + It("stops pulling from the source once writing fails", func() { + const total = 20000 + pulled := 0 + seq := func(yield func(dto.BaseItemDto, error) bool) { + for range total { + pulled++ + if !yield(dto.BaseItemDto{Id: "a", Name: strings.Repeat("x", 200)}, nil) { + return + } + } + } + err := streamItemsEnvelope(deadWriter{}, seq, total, 0) + Expect(err).To(HaveOccurred()) + Expect(pulled).To(BeNumerically("<", total), "should abandon the scan, not drain it") + }) + + It("aborts on a mid-stream error, leaving the envelope open (malformed) so the client fails loudly", func() { + boom := errors.New("scan failed") + first := dto.BaseItemDto{Id: "a", Name: "One"} + seq := func(yield func(dto.BaseItemDto, error) bool) { + if !yield(first, nil) { + return + } + yield(dto.BaseItemDto{}, boom) + } + var got bytes.Buffer + err := streamItemsEnvelope(&got, seq, 7, 0) + Expect(err).To(MatchError(boom)) + firstJSON, _ := json.Marshal(first) + Expect(got.String()).To(Equal("{\"Items\":[" + string(firstJSON))) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go new file mode 100644 index 000000000..e007dedf4 --- /dev/null +++ b/server/jellyfin/routing_test.go @@ -0,0 +1,56 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Real Jellyfin servers route path segments case-insensitively, but chi's default matching is +// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go +// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof +// that requests using non-canonical casing are still routed correctly, both when the router is used +// directly and when mounted under a parent (as it is in production via server.MountRouter). +var _ = Describe("Case-insensitive routing", func() { + var api *Router + + BeforeEach(func() { + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) + }) + + It("serves a fully lowercase path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/system/info/public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a mixed/weird-case path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/SYSTEM/Info/PUBLIC", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a lowercase login path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/users/authenticatebyname", nil) + api.ServeHTTP(w, r) + // MockDataStore has no users, so authentication itself may fail downstream, but the + // route must be found (not a 404) to prove case-insensitive matching worked. + Expect(w.Code).ToNot(Equal(http.StatusNotFound)) + }) + + It("serves a lowercase path when mounted under a parent router, replicating production", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", api) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/jellyfin/system/info/public", nil) + parent.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go new file mode 100644 index 000000000..f462f283a --- /dev/null +++ b/server/jellyfin/sessions.go @@ -0,0 +1,115 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo +// fields Navidrome needs to keep its playback/scrobbling state in sync. +type playbackReport struct { + ItemId string `json:"ItemId"` + PositionTicks int64 `json:"PositionTicks"` + IsPaused bool `json:"IsPaused"` +} + +// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send +// it there) and is decoded here since it flows straight into scrobbler lookups by media file id. +// Finamp reports restored-queue playback with truncated ids, hence resolveItemID. +func (api *Router) decodeReport(r *http.Request) playbackReport { + var body playbackReport + _ = json.NewDecoder(r.Body).Decode(&body) + if body.ItemId == "" { + body.ItemId = r.URL.Query().Get("itemid") + } + body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId)) + return body +} + +// clientIdentity returns the scrobbler cache key/display name for the caller's +// player. Both are zero values if withPlayer could not resolve a player. +func clientIdentity(ctx context.Context) (id, name string) { + player, _ := request.PlayerFrom(ctx) + return player.ID, player.Client +} + +// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client starts an item. +// +// These Sessions endpoints report only the caller's own playback and never expose content, so unlike +// browse/stream they are intentionally not library-access-gated. +func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically +// (and on pause/resume/seek) while a client keeps playing an item. +func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + state := scrobbler.StatePlaying + if body.IsPaused { + state = scrobbler.StatePaused + } + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: state, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when playback ends. +// +// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch, +// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play +// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played. +func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StateStopped, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// postCapabilities acknowledges Jellyfin session-capability negotiation. +// Navidrome doesn't track per-session client capabilities, so this is a no-op. +func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go new file mode 100644 index 000000000..24f945efd --- /dev/null +++ b/server/jellyfin/sessions_test.go @@ -0,0 +1,216 @@ +package jellyfin + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring +// server/subsonic's fakePlayTracker. +type fakePlayTracker struct { + scrobbler.PlayTracker + reported []scrobbler.ReportPlaybackParams + submitted []scrobbler.Submission +} + +func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error { + f.reported = append(f.reported, p) + return nil +} + +func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error { + f.submitted = append(f.submitted, s...) + return nil +} + +// fakePlayers is a local double for core.Players, used to exercise withPlayer. +type fakePlayers struct { + core.Players + err error + registerCalls int + lastClient string + trc *model.Transcoding +} + +func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { + f.registerCalls++ + f.lastClient = client + if f.err != nil { + return nil, nil, f.err + } + return &model.Player{ID: id, Client: client}, f.trc, nil +} + +var _ = Describe("Sessions", func() { + var api *Router + var pt *fakePlayTracker + + authed := func(r *http.Request) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + pt = &fakePlayTracker{} + api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt} + }) + + Describe("reportPlaybackStart", func() { + It("reports playback start with the item id and position", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`))) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].ClientId).To(Equal("p1")) + Expect(pt.reported[0].ClientName).To(Equal("Finamp")) + }) + + It("falls back to the ItemId query param when the body has none", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil)) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s2")) + }) + }) + + Describe("reportPlaybackProgress", func() { + It("reports the playing state when not paused", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(2000))) + }) + + It("reports the paused state when IsPaused is true", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused)) + }) + }) + + Describe("reportPlaybackStopped", func() { + It("reports the stopped state and lets the scrobbler apply its play threshold", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + + invoke(api.reportPlaybackStopped, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) + // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides + // whether the play counts; we no longer force a Submit that would bypass it. + Expect(pt.reported[0].IgnoreScrobble).To(BeFalse()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) + + Describe("postCapabilities", func() { + It("returns 204 No Content and does not touch the scrobbler", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) + + api.postCapabilities(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(BeEmpty()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var fp *fakePlayers + + BeforeEach(func() { + fp = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: fp} + }) + + It("registers a player from the Emby device info and injects it into the context", func() { + var gotPlayer model.Player + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeTrue()) + Expect(gotPlayer.ID).To(Equal("dev1")) + Expect(gotPlayer.Client).To(Equal("Finamp")) + }) + + It("fails open (no player in context) when registration errors", func() { + fp.err = errors.New("boom") + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeFalse()) + }) + + // The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it + // carries no client/device info; registering it would create a junk player named " []". + It("skips registration when the request has no client or device info", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/socket?api_key=tok", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.registerCalls).To(Equal(0)) + }) +}) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go new file mode 100644 index 000000000..085080850 --- /dev/null +++ b/server/jellyfin/similar.go @@ -0,0 +1,187 @@ +package jellyfin + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real +// result beats an instant empty list, which clients cache as "no similar items exist". A var so +// tests can shorten it. +var similarWait = 10 * time.Second + +const ( + defaultSimilarLimit = 20 + maxSimilarLimit = 100 + // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the + // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. + maxInstantMixLimit = 500 +) + +// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine +// indefinitely. +const similarFetchTimeout = time.Minute + +// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the +// request times out or the client disconnects), waiting up to similarWait then answering empty. +// Identical concurrent requests share one fetch via singleflight; the key includes the user since +// mapped items embed that user's annotations. +func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult { + u, _ := request.UserFrom(ctx) + key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit) + ch := api.similarFlight.DoChan(key, func() (any, error) { + bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout) + defer cancel() + return fetch(bgCtx), nil + }) + select { + case res := <-ch: + return res.Val.(dto.QueryResult) + case <-time.After(similarWait): + return result(nil, 0, 0) + } +} + +// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same +// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are +// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. +func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarArtists(ctx, id, limit) + })) +} + +// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar +// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty +// result (not 404) so the client stops retrying. +func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult { + switch entity.(type) { + case *model.Artist: + return api.similarArtists(ctx, id, limit) + case *model.Album: + return api.similarAlbums(ctx, id, limit) + default: // *model.MediaFile + return api.similarSongs(ctx, id, limit) + } + })) +} + +// getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so +// a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty +// results, never a 404 the client would surface as an error. +func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + mf, isSong := entity.(*model.MediaFile) + if isSong { + if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) { + api.ok(w, r, result(nil, 0, 0)) + return + } + } + // Prefixed key: a mix must not share the singleflight/cache slot with a Similar request. + tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarSongs(ctx, id, limit) + }) + if !isSong { + // Container seeds: the provider's similar songs already blend the seed's own tracks. + api.ok(w, r, tail) + return + } + // The seed leads the mix and must not depend on the provider: a slow or failing provider times + // the await out with an empty tail, but the tapped track still plays. + items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)} + for _, it := range tail.Items { + if len(items) >= limit { + break + } + if it.Id != items[0].Id { + items = append(items, it) + } + } + api.ok(w, r, result(items, len(items), 0)) +} + +func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult { + artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err) + return result(nil, 0, 0) + } + present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) + items := slice.Map(present, dto.ArtistToBaseItem) + return result(items, len(items), 0) +} + +func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err) + return result(nil, 0, 0) + } + // Filter to the caller's libraries; the provider can return songs from any library. + u, _ := request.UserFrom(ctx) + var items []dto.BaseItemDto + for _, mf := range songs { + if u.HasLibraryAccess(mf.LibraryID) { + items = append(items, dto.SongToBaseItem(mf, nil)) + } + } + return result(items, len(items), 0) +} + +// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct +// "similar albums" source), keeping each album once in first-seen order and resolving it to a full +// model.Album for cover art and metadata. +func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit*5) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err) + return result(nil, 0, 0) + } + u, _ := request.UserFrom(ctx) + seen := make(map[string]bool, limit) + var items []dto.BaseItemDto + for _, s := range songs { + if s.AlbumID == "" || seen[s.AlbumID] { + continue + } + seen[s.AlbumID] = true + if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { + items = append(items, dto.AlbumToBaseItem(*al)) + if len(items) >= limit { + break + } + } + } + return result(items, len(items), 0) +} diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go new file mode 100644 index 000000000..302566195 --- /dev/null +++ b/server/jellyfin/similar_test.go @@ -0,0 +1,167 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "strconv" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("awaitSimilar", func() { + var api *Router + ctxFor := func(userID string) context.Context { + return request.WithUser(context.Background(), model.User{ID: userID}) + } + shortenWait := func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + } + + BeforeEach(func() { + api = &Router{} + }) + + It("returns the fetch result when it completes within the wait", func() { + res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) + }) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("fast")) + }) + + It("returns an empty result when the fetch exceeds the wait", func() { + shortenWait() + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + <-release // hung provider; would finish caching in the background + return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) + }) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + + It("dedupes requests into the in-flight fetch", func() { + shortenWait() + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + <-release + return result(nil, 0, 0) + } + // Both calls time out, but the flight can't complete before release closes, so the + // second call must join it rather than start a new fetch. + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + close(release) + Eventually(calls.Load).Should(Equal(int32(1))) + Consistently(calls.Load, "50ms").Should(Equal(int32(1))) + }) + + It("does not share fetches across users (items embed the user's annotations)", func() { + var calls atomic.Int32 + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + return result(nil, 0, 0) + } + api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) + api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + Expect(calls.Load()).To(Equal(int32(2))) + }) + + It("hands the fetch a deadline-bounded background context", func() { + var deadline time.Time + var hasDeadline bool + api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult { + deadline, hasDeadline = ctx.Deadline() + return result(nil, 0, 0) + }) + Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever") + Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout)) + }) +}) + +// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent. +type blockingProvider struct { + external.Provider + release chan struct{} +} + +func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + <-p.release + return nil, nil +} + +// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. +type fakeSimilarProvider struct { + external.Provider + songs model.MediaFiles +} + +func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { + return p.songs[:min(count, len(p.songs))], nil +} + +var _ = Describe("getInstantMix", func() { + It("returns the seed track even when the provider fetch exceeds the wait", func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Seed Song", LibraryID: 1}, + }) + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + api := &Router{ds: ds, provider: &blockingProvider{release: release}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("Seed Song")) + }) + + // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the + // queue, so InstantMix gets its own, higher ceiling. + It("honors a mix-sized limit above the Similar ceiling", func() { + const want = 250 + songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + for i := range want + 50 { // more than requested, so only the limit bounds the result + songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + } + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") + Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") + }) +}) diff --git a/server/jellyfin/socket.go b/server/jellyfin/socket.go new file mode 100644 index 000000000..80a49cd4c --- /dev/null +++ b/server/jellyfin/socket.go @@ -0,0 +1,55 @@ +package jellyfin + +import ( + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/log" +) + +// socketKeepAliveInterval (seconds) is sent in the initial ForceKeepAlive telling the client how +// often to send KeepAlive, and bounds the local read deadline. +const socketKeepAliveInterval = 60 + +// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed +// KeepAlive doesn't drop the connection. +const socketReadTimeout = 90 * time.Second + +var socketUpgrader = websocket.Upgrader{ + // Jellyfin clients aren't browsers, so there's no cross-origin risk; the connection is + // already authenticated via api_key. + CheckOrigin: func(*http.Request) bool { return true }, +} + +// handleSocket implements Jellyfin's /socket WebSocket endpoint. Finamp opens it right after login +// and 404-loop-reconnects without it. Minimal: keeps the connection alive and answers KeepAlive +// pings, with no session/playstate push. +func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) { + conn, err := socketUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err) + return + } + defer conn.Close() + + if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err) + return + } + + for { + _ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout)) + var msg struct { + MessageType string `json:"MessageType"` + } + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.MessageType == "KeepAlive" { + if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil { + return + } + } + } +} diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go new file mode 100644 index 000000000..d9d74bcb9 --- /dev/null +++ b/server/jellyfin/socket_test.go @@ -0,0 +1,125 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("handleSocket", func() { + var api *Router + + BeforeEach(func() { + api = &Router{} + }) + + // Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without + // a working handshake here they 404-loop-reconnect instead of settling into a session. + It("upgrades the connection and sends ForceKeepAlive", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + Expect(msg["Data"]).To(BeNumerically("==", 60)) + }) + + It("replies to a KeepAlive message with a KeepAlive of its own", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive")) + + Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var reply map[string]any + Expect(conn.ReadJSON(&reply)).To(Succeed()) + Expect(reply["MessageType"]).To(Equal("KeepAlive")) + }) + + It("closes the connection when the client disconnects, without leaving the handler hanging", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + + Expect(conn.Close()).To(Succeed()) + }) + + // End-to-end: proves /socket is reachable through the full router (case-insensitive + // wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp + // hits it in production with ?api_key=. + Context("mounted behind the full router and auth middleware", func() { + var ds *tests.MockDataStore + var token string + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + token = t + + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil) + }) + + It("upgrades when authenticated via the api_key query parameter", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + }) + + It("rejects the upgrade with no api_key", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go new file mode 100644 index 000000000..28411f9d1 --- /dev/null +++ b/server/jellyfin/stream.go @@ -0,0 +1,164 @@ +package jellyfin + +import ( + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its +// library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. +// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. +func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + u, _ := request.UserFrom(ctx) + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return mf, true +} + +// getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct +// playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). +func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + src := dto.MediaSourceFromMediaFile(*mf) + // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl + // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients + // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so + // a client resolving it as an absolute host path still hits the mounted router. + if token := tokenFromRequest(r); token != "" { + src.TranscodingSubProtocol = "http" + src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + } + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) +} + +// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, +// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + p := req.Params(r) + + format := p.StringOr("container", "") + if format == "" { + // The /stream.{container} route form carries the format as a path segment, not a query param. + format = chi.URLParam(r, "container") + } + if format == "" { + // Jellyfin's audioCodec param names the target codec when no container is given. + format = p.StringOr("audiocodec", "") + } + if p.BoolOr("static", false) { + format = "raw" + } + + // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. + bitRate := p.IntOr("audiobitrate", 0) / 1000 + if bitRate == 0 { + bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 + } + + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} + +// streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD +// playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. +// Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. +func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + + // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced + // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. + codec := strings.ToLower(p.StringOr("audiocodec", "")) + if codec != "mp3" { + codec = "aac" + } + if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { + codec = strings.ToLower(trc.TargetFormat) + } + + // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. + segment := "stream." + codec + q := url.Values{} + if token := tokenFromRequest(r); token != "" { + q.Set("api_key", token) + } + if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { + q.Set("audioBitRate", strconv.Itoa(bitRate)) + } + if len(q) > 0 { + segment += "?" + q.Encode() + } + + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + //nolint:gosec // not HTML; the only tainted value is query-escaped + fmt.Fprintf(w, "#EXTM3U\n"+ + "#EXT-X-VERSION:3\n"+ + "#EXT-X-PLAYLIST-TYPE:VOD\n"+ + "#EXT-X-TARGETDURATION:%d\n"+ + "#EXT-X-MEDIA-SEQUENCE:0\n"+ + "#EXTINF:%.3f,\n"+ + "%s\n"+ + "#EXT-X-ENDLIST\n", + int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) +} + +// streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some +// clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so +// it must always resolve to direct play ("raw"), never a forced transcode. +func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go new file mode 100644 index 000000000..7063d321a --- /dev/null +++ b/server/jellyfin/stream_test.go @@ -0,0 +1,316 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Stream", func() { + var api *Router + var ds *tests.MockDataStore + var streamer *fakeMediaStreamer + var decider *fakeTranscodeDecider + + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + streamer = &fakeMediaStreamer{} + decider = &fakeTranscodeDecider{} + api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + }) + + Describe("getPlaybackInfo", func() { + It("returns a media source for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.MediaSources).To(HaveLen(1)) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.MediaSources[0].Container).To(Equal("mp3")) + Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) + Expect(res.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamAudio", func() { + It("invokes the transcode decider and streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("converts the bps audioBitRate param to kbps", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.BitRate).To(Equal(320)) + }) + + It("uses the audioCodec param as target format when no container is given", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.Format).To(Equal("aac")) + }) + + It("returns 500 and logs when the streamer fails", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.err = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("streamHls", func() { + BeforeEach(func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, + }) + }) + + hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamHls, w, r) + return w + } + + It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() { + w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser()) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n")) + Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n")) + Expect(body).To(ContainSubstring("#EXTINF:100.500,\n")) + Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + }) + + It("omits the bitrate param when the client doesn't send one", func() { + w := hls("?audiocodec=aac&api_key=tok", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n")) + }) + + It("falls back to aac for codecs HLS packed-audio can't carry", func() { + w := hls("?audiocodec=opus", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n")) + }) + + It("honors mp3 as segment codec", func() { + w := hls("?audiocodec=mp3", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("prefers the server-forced transcoding format over the requested codec", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n")) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2}, + }) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamFile", func() { + It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(decider.req.Format).To(Equal("raw")) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + }) +}) + +// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether +// (and how) ResolveRequest was invoked, so tests can assert it's never called on the +// access-denied path, without needing a real transcode decision pipeline. +type fakeTranscodeDecider struct { + invoked bool + req stream.Request +} + +func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { + return &stream.TranscodeDecision{}, nil +} + +func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) { + return "", nil +} + +func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) { + return stream.Request{}, nil +} + +func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + f.invoked = true + f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset} + return f.req +} + +// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether +// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed +// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. +type fakeMediaStreamer struct { + invoked bool + content string + err error +} + +func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) { + f.invoked = true + if f.err != nil { + return nil, f.err + } + return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil +} diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go new file mode 100644 index 000000000..baa31fdeb --- /dev/null +++ b/server/jellyfin/system.go @@ -0,0 +1,96 @@ +package jellyfin + +import ( + "context" + "errors" + "fmt" + "net/http" + "path" + + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate +// on it, so it must stay a real Jellyfin release, not Navidrome's own version. +const jellyfinVersion = "10.8.13" + +func (api *Router) serverName() string { + if conf.Server.Jellyfin.ServerName != "" { + return conf.Server.Jellyfin.ServerName + } + return fmt.Sprintf("Navidrome %s", consts.Version) +} + +// serverID returns a stable Id that survives restarts, get-or-created in the Property table. +// Jellyfin clients cache ServerId across sessions, so a per-process value would break +// re-authentication. api.ds is nil only in unit tests; New() always sets it. +// +// The mutex serializes first-boot resolution so concurrent requests can't persist different +// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a +// temporary id and retries on the next request rather than pinning a value. +func (api *Router) serverID(ctx context.Context) string { + api.serverIDMu.Lock() + defer api.serverIDMu.Unlock() + if api.serverIDVal != "" { + return api.serverIDVal + } + if api.ds == nil { + api.serverIDVal = uuid.NewString() + return api.serverIDVal + } + id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + switch { + case errors.Is(err, model.ErrNotFound): + id = uuid.NewString() + if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + log.Error(ctx, "Jellyfin API: could not persist server id", err) + return id + } + case err != nil: + log.Error(ctx, "Jellyfin API: could not read server id", err) + return uuid.NewString() + } + api.serverIDVal = id + return api.serverIDVal +} + +func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { + return dto.PublicSystemInfo{ + LocalAddress: localAddress(r), + ServerName: api.serverName(), + Version: jellyfinVersion, + ProductName: "Jellyfin Server", + Id: api.serverID(r.Context()), + StartupWizardCompleted: true, + } +} + +// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring +// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as +// its server base URL; without it its SDK api instance is undefined and sign-in crashes. +func localAddress(r *http.Request) string { + scheme, host := server.ServerAddress(r) + return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI) +} + +func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, api.publicInfo(r)) +} + +// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's +// server does this and clients parse the raw body. +func (api *Router) ping(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(api.serverName())) +} + +func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, false) +} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go new file mode 100644 index 000000000..5ea525100 --- /dev/null +++ b/server/jellyfin/system_test.go @@ -0,0 +1,124 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + var api *Router + BeforeEach(func() { api = &Router{} }) + + It("returns public system info without auth", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.getPublicSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.Id).ToNot(BeEmpty()) + Expect(info.Version).To(Equal(jellyfinVersion)) + Expect(info.ProductName).To(Equal("Jellyfin Server")) + Expect(info.ServerName).To(HavePrefix("Navidrome")) + }) + + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { + DeferCleanup(configtest.SetupConfig()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + r.Host = "music.example.com:4599" + api.getPublicSystemInfo(w, r) + + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + // Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the + // SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path. + Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin")) + }) + + It("responds to ping with the server name as plain text", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Ping", nil) + api.ping(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain")) + // Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name. + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("reports quick connect as disabled", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) + api.quickConnectEnabled(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var enabled bool + Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed()) + Expect(enabled).To(BeFalse()) + }) + + Context("serverID with a real DataStore", func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() { + first := &Router{ds: ds} + id := first.serverID(ctx) + Expect(id).ToNot(BeEmpty()) + + second := &Router{ds: ds} + Expect(second.serverID(ctx)).To(Equal(id)) + }) + + It("memoizes the id across repeated calls on the same Router", func() { + r := &Router{ds: ds} + id := r.serverID(ctx) + Expect(r.serverID(ctx)).To(Equal(id)) + Expect(r.serverID(ctx)).To(Equal(id)) + }) + + It("does not overwrite or pin over a stored id when the property read fails transiently", func() { + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed()) + + r := &Router{ds: ds} + props := ds.Property(ctx).(*tests.MockedPropertyRepo) + props.Error = errors.New("database is locked") + degraded := r.serverID(ctx) + Expect(degraded).ToNot(BeEmpty()) + Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one + props.Error = nil + + // Once the DB recovers, the stored id is intact and served again. + Expect(r.serverID(ctx)).To(Equal("stable-id")) + stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal("stable-id")) + }) + }) +}) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go new file mode 100644 index 000000000..b58296391 --- /dev/null +++ b/server/jellyfin/truncated_ids.go @@ -0,0 +1,113 @@ +package jellyfin + +import ( + "context" + "slices" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming +// Jellyfin GUIDs). No Navidrome id family is 16 chars (nanoid=22, legacy MD5=32, playlist +// UUID=36), so the length alone identifies a truncated id. See README. +// +// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers +// and ParentId scoping don't (a restored queue never edits playlists or browses by container id). +const truncatedIDLen = 16 + +// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is +// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous. +func (api *Router) resolveItemID(ctx context.Context, id string) string { + if len(id) != truncatedIDLen { + return id + } + probes := []func() []string{ + func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) }, + func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) }, + func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) }, + func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) }, + } + for _, probe := range probes { + switch ids := probe(); len(ids) { + case 0: + continue + case 1: + log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0]) + return ids[0] + default: + log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id) + return id + } + } + return id +} + +// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of +// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the +// leftovers (containers, unknowns) fall back to the per-id probes. +func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string { + var truncated []string + for _, id := range ids { + if len(id) == truncatedIDLen { + truncated = append(truncated, id) + } + } + if len(truncated) == 0 { + return ids + } + + byPrefix := make(map[string][]string, len(truncated)) + for chunk := range slice.CollectChunks(slices.Values(truncated), 100) { + ranges := make(squirrel.Or, len(chunk)) + for i, p := range chunk { + ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}} + } + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges}) + if err != nil { + log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err) + break + } + for _, mf := range mfs { + p := mf.ID[:truncatedIDLen] + byPrefix[p] = append(byPrefix[p], mf.ID) + } + } + + out := make([]string, len(ids)) + for i, id := range ids { + switch full := byPrefix[id]; { + case len(full) == 1: + out[i] = full[0] + case len(id) == truncatedIDLen: + out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide + default: + out[i] = id + } + } + return out +} + +// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to +// detect ambiguity). '\x7f' is above every character the id alphabets use. +func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string { + rows, err := getAll(model.QueryOptions{ + Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}}, + Max: 2, + }) + if err != nil { + return nil + } + ids := make([]string, len(rows)) + for i, row := range rows { + ids[i] = id(row) + } + return ids +} + +func mediaFileID(mf model.MediaFile) string { return mf.ID } +func albumID(al model.Album) string { return al.ID } +func artistID(ar model.Artist) string { return ar.ID } +func playlistID(pl model.Playlist) string { return pl.ID } diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go new file mode 100644 index 000000000..bbc60c892 --- /dev/null +++ b/server/jellyfin/users.go @@ -0,0 +1,61 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// getUserViews returns one CollectionFolder view per accessible library, so clients browse each +// library as its own top-level view rather than one aggregate. +func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + views := make([]dto.BaseItemDto, 0, len(u.Libraries)) + for _, lib := range u.Libraries { + views = append(views, libraryView(lib)) + } + api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) +} + +func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx))) +} + +// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login +// picker. The route is unauthenticated, so it lists only the configured allowlist (never the full +// user table) and returns a minimal DTO — no Policy/Configuration, which would leak admin status. +func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + serverID := api.serverID(ctx) + seen := make(map[string]bool) + users := []dto.UserDto{} + for name := range strings.SplitSeq(conf.Server.Jellyfin.ExposedPublicUsers, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + usr, err := api.ds.User(ctx).FindByUsername(name) + if err != nil { + log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) + continue + } + users = append(users, dto.UserDto{ + Name: usr.UserName, + Id: dto.EncodeID(usr.ID), + ServerId: serverID, + HasPassword: true, + }) + } + api.ok(w, r, users) +} diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go new file mode 100644 index 000000000..6a1597b70 --- /dev/null +++ b/server/jellyfin/users_test.go @@ -0,0 +1,130 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Users", func() { + var api *Router + authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + return r.WithContext(ctx) + } + BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) + + Describe("getUserViews", func() { + It("returns one view per accessible library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Podcasts"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(2)) + + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Name).To(Equal("Music")) + Expect(res.Items[0].Type).To(Equal("CollectionFolder")) + Expect(res.Items[0].CollectionType).To(Equal("music")) + Expect(res.Items[0].IsFolder).To(BeTrue()) + + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[1].Name).To(Equal("Podcasts")) + }) + + It("returns a single view for a user with one library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + }) + + It("returns no views for a user with no library access", func() { + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(0)) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + }) + + It("returns the current user", func() { + w := httptest.NewRecorder() + api.getCurrentUser(w, authedWithLibraries(httptest.NewRequest("GET", "/Users/Me", nil), nil)) + var u dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed()) + Expect(u.Name).To(Equal("alice")) + Expect(u.Policy).ToNot(BeNil()) + Expect(u.Policy.IsAdministrator).To(BeFalse()) + Expect(u.Configuration).ToNot(BeNil()) + }) + + Describe("getPublicUsers", func() { + var ur *tests.MockedUserRepo + publicUsers := func() []dto.UserDto { + w := httptest.NewRecorder() + api.getPublicUsers(w, httptest.NewRequest("GET", "/Users/Public", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &users)).To(Succeed()) + return users + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed()) + }) + + It("returns an empty list when the config is unset", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users in order, without leaking policy", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "bob, alice" + users := publicUsers() + Expect(users).To(HaveLen(2)) + Expect(users[0].Name).To(Equal("bob")) + Expect(users[0].Id).To(Equal(dto.EncodeID("u2"))) + Expect(users[1].Name).To(Equal("alice")) + // The public list must not expose Policy/Configuration to unauthenticated callers. + Expect(users[0].Policy).To(BeNil()) + Expect(users[0].Configuration).To(BeNil()) + }) + + It("skips a configured username that does not exist", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "alice,ghost" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + + It("matches usernames case-insensitively and de-duplicates", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "ALICE, alice" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + }) +}) diff --git a/server/middlewares.go b/server/middlewares.go index 5d6a1e59c..23e11eaa6 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -202,10 +202,10 @@ func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.H func serverAddressMiddleware(h http.Handler) http.Handler { // Define a new handler function that will be returned by this middleware function. fn := func(w http.ResponseWriter, r *http.Request) { - // Call the serverAddress function to get the scheme and host of the server + // Call the ServerAddress function to get the scheme and host of the server // handling the request. If a host is found, modify the request object to use // that host and scheme instead of the original ones. - if rScheme, rHost := serverAddress(r); rHost != "" { + if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme } @@ -225,10 +225,10 @@ var ( xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") ) -// serverAddress is a helper function that returns the scheme and host of the server +// ServerAddress is a helper function that returns the scheme and host of the server // handling the given request, as determined by the presence of X-Forwarded-* headers // or the scheme and host of the request URL. -func serverAddress(r *http.Request) (scheme, host string) { +func ServerAddress(r *http.Request) (scheme, host string) { // Save the original request host for later comparison. origHost := r.Host diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 5e2d29876..077eac35e 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,23 +13,14 @@ import ( "path/filepath" "strings" - "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -func maxImageUploadSize() int64 { - if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { - return int64(size) - } - size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) - return int64(size) -} - func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) if !conf.Server.EnableArtworkUpload && !user.IsAdmin { @@ -40,7 +31,7 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { - maxImageSize := maxImageUploadSize() + maxImageSize := core.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go deleted file mode 100644 index 291912e67..000000000 --- a/server/nativeapi/image_upload_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package nativeapi - -import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("maxImageUploadSize", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("returns the configured size when valid", func() { - conf.Server.MaxImageUploadSize = "20MB" - Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) - }) - - It("returns the default size when config is empty", func() { - conf.Server.MaxImageUploadSize = "" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("returns the default size when config is invalid", func() { - conf.Server.MaxImageUploadSize = "not-a-size" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("parses raw byte values", func() { - conf.Server.MaxImageUploadSize = "52428800" - Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) - }) -}) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 18bfcc01c..76f674483 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,6 +97,22 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { return &s } +// encodeMediafileShare builds the signed token embedded in a public share link +// for a single track. +// +// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication. +// The token is a signed, opaque capability that identifies one shared track +// (plus its transcode format/bitrate and the parent share id). We use a JWT here +// (reusing the library we already have) because it is a simple way to get three +// properties for a public link: the embedded ids can't be enumerated by guessing, +// the signature +// makes the claims tamper-evident, and the self-contained exp lets us reject +// stale links without a DB lookup. It carries no user identity (no subject, no +// admin flag) and grants access to nothing beyond the share it belongs to; the +// stream handler still verifies the share exists, is unexpired, and that the +// track is actually a member of it. An attacker who can forge these tokens +// necessarily already holds the signing secret, which also signs real user +// sessions, so that scenario is out of scope for the share boundary specifically. func encodeMediafileShare(s model.Share, id string) string { claims := auth.Claims{ ID: id, diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 8fc407e9e..15abab693 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -3,6 +3,7 @@ package public import ( "errors" "net/http" + "slices" "strconv" "time" @@ -25,23 +26,20 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - var shareOwner *model.User - 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 - } - shareOwner, err = pub.ds.User(ctx).Get(share.UserID) - if err != nil { - log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } + 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 + } + shareOwner, err := pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -56,7 +54,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { } // 404 rather than 403 so the response doesn't reveal whether the id exists. - if shareOwner != nil && !shareOwner.HasLibraryAccess(mf.LibraryID) { + // The track must belong to the share AND be within the owner's libraries. + if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) { http.Error(w, "not found", http.StatusNotFound) return } @@ -98,6 +97,15 @@ type shareTrackInfo struct { shareID string } +func shareContainsTrack(share *model.Share, mediaFileID string) bool { + return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool { + return mf.ID == mediaFileID + }) +} + +// decodeStreamInfo decodes the signed share-link token. This is a scoped +// public-share capability, not an auth credential; see encodeMediafileShare for +// why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { c, err := auth.Validate(tokenString) if err != nil { @@ -106,6 +114,9 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } + if c.ShareID == "" { + return shareTrackInfo{}, errors.New("required claim \"sid\" not found") + } return shareTrackInfo{ id: c.ID, format: c.Format, diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 6fa083045..2f32ea6f2 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -71,14 +71,11 @@ var _ = Describe("decodeStreamInfo", func() { Expect(err).To(HaveOccurred()) }) - It("handles tokens without shareID (backward compat)", func() { + It("rejects a token without a shareID claim", 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()) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) }) }) @@ -133,7 +130,7 @@ var _ = Describe("handleStream", func() { shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID} + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} userRepo := tests.CreateMockUserRepo() Expect(userRepo.Put(&owner)).To(Succeed()) ds.MockedUser = userRepo @@ -171,6 +168,25 @@ var _ = Describe("handleStream", func() { Expect(streamer.called).To(BeFalse()) }) + It("returns 404 when the track is not a member of the share", func() { + owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) + ds.MockedMediaFile = mfRepo + shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}} + + claims := auth.Claims{ID: "mf-other", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + It("streams a track inside the share owner's libraries", func() { shareOwnedBy( model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, @@ -217,12 +233,12 @@ var _ = Describe("handleStream", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) - It("skips share check for tokens without shareID (backward compat)", func() { + It("returns 400 for tokens without a shareID", 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)) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(streamer.called).To(BeFalse()) }) It("returns 400 for an invalid token", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 24bbca960..041a3b8f2 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/run" diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 817238aaf..f6a7047c4 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" diff --git a/server/e2e/doc.go b/server/subsonic/e2e/doc.go similarity index 98% rename from server/e2e/doc.go rename to server/subsonic/e2e/doc.go index 51ee6f047..9435d1f60 100644 --- a/server/e2e/doc.go +++ b/server/subsonic/e2e/doc.go @@ -103,7 +103,7 @@ // // The e2e tests are included in the standard test suite and can be run with: // -// make test PKG=./server/e2e # Run only e2e tests +// make test PKG=./server/subsonic/e2e # Run only e2e tests // make test # Run all tests including e2e // make test-race # Run with race detector // diff --git a/server/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go similarity index 75% rename from server/e2e/e2e_suite_test.go rename to server/subsonic/e2e/e2e_suite_test.go index ac4aaa5f2..6875b6370 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -4,14 +4,12 @@ import ( "bytes" "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" - "strings" "testing" "testing/fstest" "time" @@ -22,7 +20,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" @@ -40,6 +37,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,13 +87,10 @@ var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router - streamerSpy *spyStreamer + streamerSpy *harness.SpyStreamer + goldenDB *harness.DB lib model.Library - // Snapshot paths for fast DB restore - dbFilePath string - snapshotPath string - // Admin user used for most tests adminUser = model.User{ ID: "admin-1", @@ -113,13 +108,6 @@ var ( } ) -func createFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - // buildTestFS creates the full test filesystem matching the plan func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{ @@ -145,7 +133,7 @@ func buildTestFS() storagetest.FakeFS { // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) - return createFS(fstest.MapFS{ + return harness.CreateFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). @@ -331,61 +319,6 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// spyStreamer captures the Request passed to NewStream for test assertions, -// then returns a minimal fake Stream so the handler completes without error. -type spyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile - SimulateError error // When set, NewStream returns this error - SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) -} - -func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - s.LastRequest = req - s.LastMediaFile = mf - if s.SimulateError != nil { - return nil, s.SimulateError - } - format := req.Format - if format == "" || format == "raw" { - format = mf.Suffix - } - content := "fake audio data" - if s.SimulateEmptyStream { - content = "" - } - r := io.NopCloser(strings.NewReader(content)) - return stream.NewStream(mf, format, req.BitRate, r), nil -} - -// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. -type noopFFmpeg struct{} - -func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: transcode not supported") -} - -func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: extract image not supported") -} - -func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { - return "", nil -} - -func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - return nil, errors.New("noop ffmpeg: probe not supported") -} - -func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: convert animated image not supported") -} - -func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } -func (n noopFFmpeg) IsAvailable() bool { return false } -func (n noopFFmpeg) IsProbeAvailable() bool { return true } -func (n noopFFmpeg) Version() string { return "noop" } - // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -434,67 +367,22 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} ) var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) - tmpDir := GinkgoT().TempDir() - dbFilePath = filepath.Join(tmpDir, "test-e2e.db") - snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") - conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" - db.Db().SetMaxOpenConns(1) - // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false - db.Init(ctx) - - initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(initDS) - - adminUserWithPass := adminUser - adminUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) - - regularUserWithPass := regularUser - regularUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) - - lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - - Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) - Expect(err).ToNot(HaveOccurred()) - adminUser.Libraries = loadedUser.Libraries - - loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName) - Expect(err).ToNot(HaveOccurred()) - regularUser.Libraries = loadedRegular.Libraries - - ctx = request.WithUser(GinkgoT().Context(), adminUser) - buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err = s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Checkpoint WAL and snapshot the golden DB state - _, 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()) + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + lib = goldenDB.Library + ctx = request.WithUser(GinkgoT().Context(), adminUser) }) // Close the database before the suite's TempDir cleanup runs. Required on @@ -520,14 +408,14 @@ func setupTestDB() { conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) - restoreDB() + goldenDB.Restore() ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) // Create the Subsonic Router with real DS, streamer spy, and real Decider - streamerSpy = &spyStreamer{} - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + streamerSpy = &harness.SpyStreamer{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( @@ -549,39 +437,3 @@ func setupTestDB() { nil, ) } - -// restoreDB restores all table data from the snapshot using ATTACH DATABASE. -// This is much faster than re-running the scanner for each test. -func restoreDB() { - sqlDB := db.Db() - - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) - Expect(err).ToNot(HaveOccurred()) - - rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") - Expect(err).ToNot(HaveOccurred()) - var tables []string - for rows.Next() { - var name string - Expect(rows.Scan(&name)).To(Succeed()) - tables = append(tables, name) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - rows.Close() - - for _, table := range tables { - // Table names come from sqlite_master, not user input, so concatenation is safe here - _, 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("DETACH DATABASE snapshot") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/subsonic/e2e/subsonic_album_lists_test.go similarity index 100% rename from server/e2e/subsonic_album_lists_test.go rename to server/subsonic/e2e/subsonic_album_lists_test.go diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/subsonic/e2e/subsonic_bookmarks_test.go similarity index 100% rename from server/e2e/subsonic_bookmarks_test.go rename to server/subsonic/e2e/subsonic_bookmarks_test.go diff --git a/server/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go similarity index 100% rename from server/e2e/subsonic_browsing_test.go rename to server/subsonic/e2e/subsonic_browsing_test.go diff --git a/server/e2e/subsonic_lyrics_test.go b/server/subsonic/e2e/subsonic_lyrics_test.go similarity index 100% rename from server/e2e/subsonic_lyrics_test.go rename to server/subsonic/e2e/subsonic_lyrics_test.go diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go similarity index 100% rename from server/e2e/subsonic_media_annotation_test.go rename to server/subsonic/e2e/subsonic_media_annotation_test.go diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go similarity index 100% rename from server/e2e/subsonic_media_retrieval_test.go rename to server/subsonic/e2e/subsonic_media_retrieval_test.go diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go similarity index 100% rename from server/e2e/subsonic_multilibrary_test.go rename to server/subsonic/e2e/subsonic_multilibrary_test.go diff --git a/server/e2e/subsonic_multiuser_test.go b/server/subsonic/e2e/subsonic_multiuser_test.go similarity index 100% rename from server/e2e/subsonic_multiuser_test.go rename to server/subsonic/e2e/subsonic_multiuser_test.go diff --git a/server/e2e/subsonic_playlists_test.go b/server/subsonic/e2e/subsonic_playlists_test.go similarity index 100% rename from server/e2e/subsonic_playlists_test.go rename to server/subsonic/e2e/subsonic_playlists_test.go diff --git a/server/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go similarity index 100% rename from server/e2e/subsonic_radio_test.go rename to server/subsonic/e2e/subsonic_radio_test.go diff --git a/server/e2e/subsonic_scan_test.go b/server/subsonic/e2e/subsonic_scan_test.go similarity index 100% rename from server/e2e/subsonic_scan_test.go rename to server/subsonic/e2e/subsonic_scan_test.go diff --git a/server/e2e/subsonic_searching_test.go b/server/subsonic/e2e/subsonic_searching_test.go similarity index 100% rename from server/e2e/subsonic_searching_test.go rename to server/subsonic/e2e/subsonic_searching_test.go diff --git a/server/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go similarity index 100% rename from server/e2e/subsonic_sharing_test.go rename to server/subsonic/e2e/subsonic_sharing_test.go diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go similarity index 98% rename from server/e2e/subsonic_sonic_similarity_test.go rename to server/subsonic/e2e/subsonic_sonic_similarity_test.go index 1b8d34eb1..775fefe89 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -32,11 +33,11 @@ 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{}) + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) return subsonic.New( ds, noopArtwork{}, - &spyStreamer{}, + &harness.SpyStreamer{}, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, diff --git a/server/e2e/subsonic_stream_test.go b/server/subsonic/e2e/subsonic_stream_test.go similarity index 100% rename from server/e2e/subsonic_stream_test.go rename to server/subsonic/e2e/subsonic_stream_test.go diff --git a/server/e2e/subsonic_system_test.go b/server/subsonic/e2e/subsonic_system_test.go similarity index 100% rename from server/e2e/subsonic_system_test.go rename to server/subsonic/e2e/subsonic_system_test.go diff --git a/server/e2e/subsonic_transcode_test.go b/server/subsonic/e2e/subsonic_transcode_test.go similarity index 100% rename from server/e2e/subsonic_transcode_test.go rename to server/subsonic/e2e/subsonic_transcode_test.go diff --git a/server/e2e/subsonic_users_test.go b/server/subsonic/e2e/subsonic_users_test.go similarity index 100% rename from server/e2e/subsonic_users_test.go rename to server/subsonic/e2e/subsonic_users_test.go diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index e8b0278c1..27170c11b 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "errors" "fmt" "math" "net/http" @@ -52,6 +53,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { case *model.Album: repo = api.ds.Album(ctx) resource = "album" + case *model.Playlist: + repo = api.ds.Playlist(ctx) + resource = "playlist" default: repo = api.ds.MediaFile(ctx) resource = "song" @@ -104,48 +108,50 @@ func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { - if len(ids) == 0 { - return nil - } - log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) if len(ids) == 0 { log.Warn(ctx, "Cannot star/unstar an empty list of ids") return nil } - event := &events.RefreshResource{} + log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + event := &events.RefreshResource{} + changed := false for _, id := range ids { - exist, err := tx.Album(ctx).Exists(id) + var repo model.AnnotatedRepository + var resource string + entity, err := model.GetEntityByID(ctx, tx, id) if err != nil { - return err - } - if exist { - err = tx.Album(ctx).SetStar(star, id) - if err != nil { + if !errors.Is(err, model.ErrNotFound) { return err } - event = event.With("album", id) + log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) continue } - exist, err = tx.Artist(ctx).Exists(id) - if err != nil { + switch entity.(type) { + case *model.Artist: + repo = tx.Artist(ctx) + resource = "artist" + case *model.Album: + repo = tx.Album(ctx) + resource = "album" + case *model.Playlist: + repo = tx.Playlist(ctx) + resource = "playlist" + default: + repo = tx.MediaFile(ctx) + resource = "song" + } + if err := repo.SetStar(star, id); err != nil { return err } - if exist { - err = tx.Artist(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("artist", id) - continue - } - err = tx.MediaFile(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("song", id) + event = event.With(resource, id) + changed = true + } + // Skip the broadcast when nothing changed: an empty RefreshResource + // serializes as a "{*:*}" wildcard, forcing every client to refresh. + if changed { + api.broker.SendMessage(ctx, event) } - api.broker.SendMessage(ctx, event) return nil }) if err != nil { diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 487335d1a..1b16dfc68 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -185,6 +185,64 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) }) }) + + Describe("Star/Unstar playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("stars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", true)) + }) + + It("unstars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Unstar(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", false)) + }) + }) + + Describe("SetRating playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("rates a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1", "rating=4") + + _, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4)) + }) + }) + + Describe("Star with an unresolvable id", func() { + It("skips the id without broadcasting an empty (wildcard) refresh", func() { + r := newGetRequest("id=does-not-exist") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.Events).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 1d5f6a70a..697dd5852 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "time" "github.com/navidrome/navidrome/conf" @@ -248,6 +249,27 @@ var _ = Describe("buildPlaylist", func() { }) }) }) + + Describe("annotation leakage", func() { + It("does not serialize starred/rating even when the model carries them", func() { + p := model.Playlist{ID: "pl-1", Name: "My Playlist"} + p.Starred = true + p.Rating = 5 + + resp := router.buildPlaylist(ctx, p) + + data, err := json.Marshal(resp) + Expect(err).ToNot(HaveOccurred()) + var fields map[string]any + Expect(json.Unmarshal(data, &fields)).To(Succeed()) + Expect(fields).ToNot(HaveKey("starred")) + Expect(fields).ToNot(HaveKey("starredAt")) + Expect(fields).ToNot(HaveKey("rating")) + Expect(fields).ToNot(HaveKey("userRating")) + Expect(fields).ToNot(HaveKey("averageRating")) + Expect(fields).ToNot(HaveKey("playCount")) + }) + }) }) var _ = Describe("UpdatePlaylist", func() { diff --git a/tests/harness/harness.go b/tests/harness/harness.go new file mode 100644 index 000000000..ff5ce8919 --- /dev/null +++ b/tests/harness/harness.go @@ -0,0 +1,178 @@ +// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and +// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, +// and service doubles. Like core/storage/storagetest, it must only be imported from test code. +package harness + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "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" //nolint:staticcheck +) + +// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. +type DB struct { + FilePath string + SnapshotPath string + Library model.Library +} + +// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. +func CreateFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + +// SetupDB boots the golden database: a temp SQLite file, the given users (password "password", +// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a +// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS +// first; each user's Libraries field is populated in place. +func SetupDB(ctx context.Context, users ...*model.User) *DB { + tmpDir := ginkgo.GinkgoT().TempDir() + h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} + h.SnapshotPath = h.FilePath + ".snapshot" + conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + db.Init(ctx) + + ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&h.Library)).To(Succeed()) + + for _, u := range users { + seeded := *u + seeded.NewPassword = "password" + Expect(ds.User(ctx).Put(&seeded)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(u.ID, []int{h.Library.ID})).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername(u.UserName) + Expect(err).ToNot(HaveOccurred()) + u.Libraries = loaded.Libraries + } + + 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()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(h.FilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir + return h +} + +// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a +// rescan. FTS shadow tables are skipped; they are kept in sync by their content tables' triggers. +func (h *DB) Restore() { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input. + _, 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("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. +type SpyStreamer struct { + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // when set, NewStream returns this error + SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) +} + +func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { + s.LastRequest = req + s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } + format := req.Format + if format == "" || format == "raw" { + format = mf.Suffix + } + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil +} + +// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. +type NoopFFmpeg struct{} + +func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil } + +func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: convert animated image not supported") +} + +func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } +func (NoopFFmpeg) IsAvailable() bool { return false } +func (NoopFFmpeg) IsProbeAvailable() bool { return true } +func (NoopFFmpeg) Version() string { return "noop" } + +var ( + _ stream.MediaStreamer = &SpyStreamer{} + _ ffmpeg.FFmpeg = NoopFFmpeg{} +) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 3428813f6..03dfed879 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -20,6 +20,7 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions + SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID } @@ -75,6 +76,20 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.Album, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("unexpected error") @@ -120,6 +135,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { + m.SearchQuery = q if len(options) > 0 { m.Options = options[0] } @@ -174,6 +190,9 @@ func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { if m.Err { return errors.New("unexpected error") } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } return nil } @@ -182,6 +201,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { if m.Err { return errors.New("unexpected error") } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } return nil } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b7a6fb811..e6ea7aea4 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -73,6 +73,28 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockArtistRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockArtistRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] @@ -91,6 +113,20 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Artist, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { if m.Err { return errors.New("mock repo error") @@ -145,6 +181,13 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } +func (m *MockArtistRepo) CountAll(...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f15ba1bc6..990b91d7c 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -109,6 +109,20 @@ func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFile return res, nil } +func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range res { + if !yield(mf, nil) { + return + } + } + }, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") @@ -154,6 +168,28 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockMediaFileRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockMediaFileRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9b38ea5b5..8f8842c8e 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -2,6 +2,7 @@ package tests import ( "errors" + "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -19,8 +20,12 @@ type MockPlaylistRepo struct { model.PlaylistRepository Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path + All model.Playlists + Options model.QueryOptions Last *model.Playlist Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating Err bool TracksRepo model.PlaylistTrackRepository } @@ -29,6 +34,38 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } +func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(playlists)) + m.All = playlists + for i, p := range m.All { + m.Data[p.ID] = &m.All[i] + } +} + +func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + +func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Playlist, error) bool) { + for _, p := range res { + if !yield(p, nil) { + return + } + } + }, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -79,6 +116,44 @@ func (m *MockPlaylistRepo) Delete(id string) error { return nil } +func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { + if m.Err { + return errors.New("error") + } + if m.Starred == nil { + m.Starred = map[string]bool{} + } + for _, id := range ids { + m.Starred[id] = starred + } + return nil +} + +func (m *MockPlaylistRepo) SetRating(rating int, id string) error { + if m.Err { + return errors.New("error") + } + if m.Ratings == nil { + m.Ratings = map[string]int{} + } + m.Ratings[id] = rating + return nil +} + +func (m *MockPlaylistRepo) IncPlayCount(string, time.Time) error { + if m.Err { + return errors.New("error") + } + return nil +} + +func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { + if m.Err { + return errors.New("error") + } + return nil +} + func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { return m.TracksRepo } diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index c11b077d2..2835baadd 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -1,9 +1,14 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) type MockPlaylistTrackRepo struct { model.PlaylistTrackRepository + Data model.PlaylistTracks + Options model.QueryOptions AddedIds []string DeletedIds []string Reordered bool @@ -11,6 +16,63 @@ type MockPlaylistTrackRepo struct { Err error } +func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { + m.Data = tracks +} + +// page applies Max/Offset as the real repository's SQL would. +func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.PlaylistTracks { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + m.Options = opts + } + tracks := m.Data + if opts.Offset >= len(tracks) { + return nil + } + tracks = tracks[opts.Offset:] + if opts.Max > 0 && opts.Max < len(tracks) { + tracks = tracks[:opts.Max] + } + return tracks +} + +func (m *MockPlaylistTrackRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockPlaylistTrackRepo) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { + if m.Err != nil { + return nil, m.Err + } + return m.page(options...), nil +} + +func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + if m.Err != nil { + return nil, m.Err + } + tracks := m.page(options...) + return func(yield func(model.PlaylistTrack, error) bool) { + for _, t := range tracks { + if !yield(t, nil) { + return + } + } + }, nil +} + +func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil +} + func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { m.AddedIds = append(m.AddedIds, ids...) if m.Err != nil { diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 0b8c256df..a860c85bb 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -28,7 +28,11 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' -import albumLists, { defaultAlbumList } from './albumLists' +import albumLists from './albumLists' +import { + getStoredDefaultView, + isResourceDefaultView, +} from '../personal/defaultViews' import config from '../config' import AlbumInfo from './AlbumInfo' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' @@ -220,8 +224,10 @@ const AlbumList = (props) => { // If it does not have filter/sort params (usually coming from Menu), // reload with correct filter/sort params if (!location.search) { - const type = - albumListType || localStorage.getItem('defaultView') || defaultAlbumList + const type = albumListType || getStoredDefaultView() + if (isResourceDefaultView(type)) { + return + } const listParams = albumLists[type] if (type === 'random') { refresh() diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx index 71c87305c..e90fd65bc 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.jsx @@ -1,13 +1,10 @@ import { SelectInput, useTranslate } from 'react-admin' -import albumLists, { defaultAlbumList } from '../album/albumLists' +import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = localStorage.getItem('defaultView') || defaultAlbumList - const choices = Object.keys(albumLists).map((type) => ({ - id: type, - name: translate(`resources.album.lists.${type}`), - })) + const current = getStoredDefaultView() + const choices = getDefaultViewChoices(translate) return ( + resourceDefaultViews.includes(defaultView) + +export const getDefaultViewChoices = (translate) => [ + ...Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })), + ...resourceDefaultViews.map((resource) => ({ + id: resource, + name: translate(`resources.${resource}.name`, { smart_count: 2 }), + })), +] + +export const getStoredDefaultView = () => + localStorage.getItem('defaultView') || defaultAlbumList diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js new file mode 100644 index 000000000..44057a736 --- /dev/null +++ b/ui/src/personal/defaultViews.test.js @@ -0,0 +1,48 @@ +import { + getDefaultViewChoices, + getStoredDefaultView, + isResourceDefaultView, + resourceDefaultViews, +} from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' + +describe('defaultViews', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('includes album lists and top-level resource lists as choices', () => { + const choices = getDefaultViewChoices((key, options) => + options?.smart_count ? `${key}:${options.smart_count}` : key, + ) + + expect(choices.map((choice) => choice.id)).toEqual([ + ...Object.keys(albumLists), + ...resourceDefaultViews, + ]) + expect(choices).toEqual( + expect.arrayContaining([ + { id: 'artist', name: 'resources.artist.name:2' }, + { id: 'song', name: 'resources.song.name:2' }, + { id: 'playlist', name: 'resources.playlist.name:2' }, + ]), + ) + }) + + it('identifies resource-backed default views', () => { + expect(isResourceDefaultView('artist')).toBe(true) + expect(isResourceDefaultView('song')).toBe(true) + expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('recentlyAdded')).toBe(false) + }) + + it('falls back to the default album list when no default view is stored', () => { + expect(getStoredDefaultView()).toBe(defaultAlbumList) + }) + + it('returns the stored default view', () => { + localStorage.setItem('defaultView', 'playlist') + + expect(getStoredDefaultView()).toBe('playlist') + }) +})