Merge branch 'master' into add-scrobble-plugin-api

This commit is contained in:
Kendall Garner 2026-07-16 17:59:44 -07:00
commit d1f963b0f8
No known key found for this signature in database
GPG Key ID: 9355F387FE765C94
157 changed files with 13020 additions and 518 deletions

View File

@ -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

View File

@ -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,

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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())
}

View File

@ -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")

View File

@ -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

View File

@ -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 {

View File

@ -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)))
})
})

View File

@ -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

View File

@ -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"`
}

View File

@ -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

View File

@ -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{

View File

@ -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
}

View File

@ -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...)) }

View File

@ -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
)

View File

@ -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;

View File

@ -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

View File

@ -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))

224
db/optimize.go Normal file
View File

@ -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
}

162
db/optimize_test.go Normal file
View File

@ -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))
})
})

26
go.mod
View File

@ -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

58
go.sum
View File

@ -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=

View File

@ -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]+",
},
}

View File

@ -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"))
})
})
})

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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")
})

View File

@ -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 {

View File

@ -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() {

View File

@ -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 {

View File

@ -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")
})

View File

@ -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
}

View File

@ -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

View File

@ -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")
})

View File

@ -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() }),

View File

@ -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
}

View File

@ -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
}

View File

@ -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"}

View File

@ -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").

View File

@ -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}))
})
})
})

View File

@ -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",

View File

@ -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) {

View File

@ -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 "<name>_id" key maps to "$.<name>".
func tagIDFilter(name string, idValue any) Sqlizer {
name = strings.TrimSuffix(name, "_id")
return Exists(

View File

@ -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": "使用增益"
}
}
},

View File

@ -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)

View File

@ -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())
})
})

View File

@ -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()

View File

@ -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))
})
})

View File

@ -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 {

352
server/jellyfin/README.md Normal file
View File

@ -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 <version>")
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://<host>:<port>/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=<libraryId>`); `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": <navidrome 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=<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=<id>&end_song_id=<id>&max_steps=25` — 404 when no provider is
loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (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).

View File

@ -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)
}

View File

@ -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))
})
})
})

223
server/jellyfin/api.go Normal file
View File

@ -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)
}

View File

@ -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))
})
})

View File

@ -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)
}

View File

@ -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))
})
})

134
server/jellyfin/auth.go Normal file
View File

@ -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: "",
}
}

View File

@ -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))
})
})

View File

@ -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)
}

View File

@ -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=<inaccessible library id> 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))
})
})
})

View File

@ -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)
}

View File

@ -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")))
})
})

258
server/jellyfin/dto/dto.go Normal file
View File

@ -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"`
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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"))
})
})

View File

@ -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),
}
}

View File

@ -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))
})
})

View File

@ -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)))
})
})
})

View File

@ -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))
})
})
})

View File

@ -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))
})
})
})

View File

@ -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=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds
// for the actual artist filter, not ParentId=<artistId>.
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=<libraryId> (scoping) plus GenreIds=<genreId>.
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))
})
})
})

View File

@ -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, &regularUser)
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{}

View File

@ -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))
})
})
})

View File

@ -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))
})
})
})

View File

@ -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=<playlistId> 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=<playlist>&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))
})
})
})

View File

@ -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))
})
})

View File

@ -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)))
})
})
})

View File

@ -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))
})
})
})

View File

@ -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!"}))
})
})
})

View File

@ -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"))
})
})

View File

@ -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))
})
})
})

View File

@ -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"))
})
})
})

170
server/jellyfin/images.go Normal file
View File

@ -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
}
}

View File

@ -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))
})
})

840
server/jellyfin/items.go Normal file
View File

@ -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=<libraryId> for scoping plus GenreIds for the genre.
genreIds: decodedQueryIDs(r, "genreids"),
}
// An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> 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=<playlist>&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=<artistId> 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
}

View File

@ -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=<id>; 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=<inaccessible library id> 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 <playlist> 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))
})
})
})

View File

@ -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)
}

View File

@ -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{},
}
}

View File

@ -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))
})
}

View File

@ -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))
})
})

View File

@ -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})
}

View File

@ -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}))
})
})
})

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