mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge branch 'master' into add-scrobble-plugin-api
This commit is contained in:
commit
d1f963b0f8
2
.github/workflows/download-link-on-pr.yml
vendored
2
.github/workflows/download-link-on-pr.yml
vendored
@ -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
|
||||
|
||||
4
.github/workflows/pipeline.yml
vendored
4
.github/workflows/pipeline.yml
vendored
@ -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,
|
||||
|
||||
25
cmd/root.go
25
cmd/root.go
@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) {
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBOptimizer(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
g.Go(startPluginManager(ctx))
|
||||
g.Go(runInitialScan(ctx))
|
||||
if conf.Server.Scanner.Enabled {
|
||||
@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error {
|
||||
if conf.Server.ListenBrainz.Enabled {
|
||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||
}
|
||||
if conf.Server.Jellyfin.Enabled {
|
||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||
}
|
||||
if conf.Server.Prometheus.Enabled {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
@ -275,16 +278,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||
if !conf.Server.EnableScheduledDBAnalyze {
|
||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||
if scanner.IsScanning() {
|
||||
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
|
||||
release, ok := scanner.LockForMaintenance()
|
||||
if !ok {
|
||||
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
|
||||
return
|
||||
}
|
||||
db.Optimize(ctx)
|
||||
defer release()
|
||||
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
37
cmd/scan.go
37
cmd/scan.go
@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
|
||||
var changesDetected bool
|
||||
var scanErrors []error
|
||||
for status := range pl.ReadOrDone(ctx, progress) {
|
||||
if status.Warning != "" {
|
||||
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
||||
}
|
||||
if status.Error != "" {
|
||||
log.Error(ctx, "Scan error", "error", status.Error)
|
||||
scanErrors = append(scanErrors, errors.New(status.Error))
|
||||
}
|
||||
if status.ChangesDetected {
|
||||
changesDetected = true
|
||||
}
|
||||
// Discard the progress status, we only care about errors
|
||||
}
|
||||
|
||||
if fullScan {
|
||||
@ -59,6 +65,7 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
|
||||
} else {
|
||||
log.Info("Finished rescan")
|
||||
}
|
||||
return changesDetected, errors.Join(scanErrors...)
|
||||
}
|
||||
|
||||
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||
@ -95,6 +102,16 @@ func runScanner(ctx context.Context) {
|
||||
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
||||
}
|
||||
|
||||
effectiveFullScan := fullScan
|
||||
if !subprocess {
|
||||
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
|
||||
if effectiveFullScan {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to scan", err)
|
||||
@ -104,7 +121,21 @@ func runScanner(ctx context.Context) {
|
||||
if subprocess {
|
||||
trackScanAsSubprocess(ctx, progress)
|
||||
} else {
|
||||
trackScanInteractively(ctx, progress)
|
||||
changesDetected, scanErr := trackScanInteractively(ctx, progress)
|
||||
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
|
||||
if changesDetected {
|
||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
||||
}
|
||||
}
|
||||
if effectiveFullScan && scanErr == nil {
|
||||
if err := db.Optimize(ctx); err != nil {
|
||||
log.Error(ctx, "Error analyzing DB", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
52
db/db.go
52
db/db.go
@ -5,7 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@ -43,16 +43,10 @@ func Db() *sql.DB {
|
||||
}
|
||||
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
|
||||
db, err := sql.Open(Driver, Path)
|
||||
db.SetMaxOpenConns(max(4, runtime.NumCPU()))
|
||||
db.SetMaxOpenConns(conf.MaxOpenConns())
|
||||
if err != nil {
|
||||
log.Fatal("Error opening database", err)
|
||||
}
|
||||
if conf.Server.DevOptimizeDB {
|
||||
_, err = db.Exec("PRAGMA optimize=0x10002")
|
||||
if err != nil {
|
||||
log.Error("Error applying PRAGMA optimize", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
})
|
||||
}
|
||||
@ -61,9 +55,6 @@ func Close(ctx context.Context) {
|
||||
// Ignore cancellations when closing the DB
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
|
||||
// Run optimize before closing
|
||||
Optimize(ctx)
|
||||
|
||||
log.Info(ctx, "Closing Database")
|
||||
err := Db().Close()
|
||||
if err != nil {
|
||||
@ -102,11 +93,11 @@ func Init(ctx context.Context) func() {
|
||||
log.Fatal(ctx, "Failed to apply new migrations", err)
|
||||
}
|
||||
|
||||
if hasSchemaChanges && conf.Server.DevOptimizeDB {
|
||||
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
|
||||
_, err = db.ExecContext(ctx, "PRAGMA optimize")
|
||||
if hasSchemaChanges {
|
||||
log.Debug(ctx, "Running ANALYZE after schema changes")
|
||||
err = optimizeAt(ctx, db, time.Now())
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error applying PRAGMA optimize", err)
|
||||
log.Error(ctx, "Error running ANALYZE", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,37 +106,6 @@ func Init(ctx context.Context) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// Optimize runs PRAGMA optimize on each connection in the pool
|
||||
func Optimize(ctx context.Context) {
|
||||
if !conf.Server.DevOptimizeDB {
|
||||
return
|
||||
}
|
||||
numConns := Db().Stats().OpenConnections
|
||||
if numConns == 0 {
|
||||
log.Debug(ctx, "No open connections to optimize")
|
||||
return
|
||||
}
|
||||
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
|
||||
var conns []*sql.Conn
|
||||
for range numConns {
|
||||
conn, err := Db().Conn(ctx)
|
||||
conns = append(conns, conn)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error getting connection from pool", err)
|
||||
continue
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, "PRAGMA optimize;")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running PRAGMA optimize", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Return all connections to the Connection Pool
|
||||
for _, conn := range conns {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
@ -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
224
db/optimize.go
Normal 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
162
db/optimize_test.go
Normal 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
26
go.mod
@ -36,7 +36,7 @@ require (
|
||||
github.com/kardianos/service v1.3.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.32.0
|
||||
@ -59,12 +59,12 @@ require (
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.39.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@ -75,7 +75,7 @@ require (
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/reflex v0.3.1 // indirect
|
||||
github.com/cespare/reflex v0.3.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
@ -89,7 +89,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@ -133,10 +133,10 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.3 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
58
go.sum
58
go.sum
@ -16,13 +16,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
|
||||
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
|
||||
github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -62,7 +61,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||
@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@ -143,11 +141,8 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@ -174,8 +169,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
@ -309,39 +304,38 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc=
|
||||
golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
|
||||
golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
@ -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]+",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
})
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
})
|
||||
|
||||
|
||||
@ -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() }),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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").
|
||||
|
||||
61
persistence/playlist_track_repository_test.go
Normal file
61
persistence/playlist_track_repository_test.go
Normal 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}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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": "使用曲目增益"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
352
server/jellyfin/README.md
Normal 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).
|
||||
131
server/jellyfin/annotations.go
Normal file
131
server/jellyfin/annotations.go
Normal 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)
|
||||
}
|
||||
257
server/jellyfin/annotations_test.go
Normal file
257
server/jellyfin/annotations_test.go
Normal 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
223
server/jellyfin/api.go
Normal 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)
|
||||
}
|
||||
87
server/jellyfin/api_test.go
Normal file
87
server/jellyfin/api_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
161
server/jellyfin/audiomuse.go
Normal file
161
server/jellyfin/audiomuse.go
Normal 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)
|
||||
}
|
||||
250
server/jellyfin/audiomuse_test.go
Normal file
250
server/jellyfin/audiomuse_test.go
Normal 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
134
server/jellyfin/auth.go
Normal 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: "",
|
||||
}
|
||||
}
|
||||
103
server/jellyfin/auth_test.go
Normal file
103
server/jellyfin/auth_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
61
server/jellyfin/browsing.go
Normal file
61
server/jellyfin/browsing.go
Normal 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)
|
||||
}
|
||||
178
server/jellyfin/browsing_test.go
Normal file
178
server/jellyfin/browsing_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
36
server/jellyfin/dto/blurhash.go
Normal file
36
server/jellyfin/dto/blurhash.go
Normal 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)
|
||||
}
|
||||
27
server/jellyfin/dto/blurhash_test.go
Normal file
27
server/jellyfin/dto/blurhash_test.go
Normal 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
258
server/jellyfin/dto/dto.go
Normal 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"`
|
||||
}
|
||||
17
server/jellyfin/dto/dto_suite_test.go
Normal file
17
server/jellyfin/dto/dto_suite_test.go
Normal 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")
|
||||
}
|
||||
24
server/jellyfin/dto/fields.go
Normal file
24
server/jellyfin/dto/fields.go
Normal 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
|
||||
}
|
||||
23
server/jellyfin/dto/ids.go
Normal file
23
server/jellyfin/dto/ids.go
Normal 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
|
||||
}
|
||||
35
server/jellyfin/dto/ids_test.go
Normal file
35
server/jellyfin/dto/ids_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
256
server/jellyfin/dto/mappers.go
Normal file
256
server/jellyfin/dto/mappers.go
Normal 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),
|
||||
}
|
||||
}
|
||||
280
server/jellyfin/dto/mappers_test.go
Normal file
280
server/jellyfin/dto/mappers_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
142
server/jellyfin/e2e/annotations_test.go
Normal file
142
server/jellyfin/e2e/annotations_test.go
Normal 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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
113
server/jellyfin/e2e/audiomuse_test.go
Normal file
113
server/jellyfin/e2e/audiomuse_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
120
server/jellyfin/e2e/auth_test.go
Normal file
120
server/jellyfin/e2e/auth_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
439
server/jellyfin/e2e/browsing_test.go
Normal file
439
server/jellyfin/e2e/browsing_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
416
server/jellyfin/e2e/e2e_suite_test.go
Normal file
416
server/jellyfin/e2e/e2e_suite_test.go
Normal 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, ®ularUser)
|
||||
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(ctx)
|
||||
})
|
||||
|
||||
// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from
|
||||
// BeforeEach in each test container.
|
||||
func setupTestDB() {
|
||||
ctx = request.WithUser(GinkgoT().Context(), adminUser)
|
||||
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MusicFolder = "fake:///music"
|
||||
conf.Server.DataFolder = conf.NewDir(dataFolder)
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.DevEnableMediaFileProbe = false
|
||||
|
||||
goldenDB.Restore()
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
auth.Init(ds)
|
||||
|
||||
streamerSpy = &harness.SpyStreamer{}
|
||||
artworkSpy = &spyArtwork{}
|
||||
providerFake = &fakeExternalProvider{}
|
||||
sonicProviderFake = &fakeSonicProvider{}
|
||||
sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds))
|
||||
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
|
||||
router = jellyfin.New(
|
||||
ds,
|
||||
artworkSpy,
|
||||
streamerSpy,
|
||||
decider,
|
||||
core.NewPlayers(ds),
|
||||
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
|
||||
playlists.NewPlaylists(ds, core.NewImageUploadService()),
|
||||
providerFake,
|
||||
sonicSvc,
|
||||
)
|
||||
}
|
||||
|
||||
// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return
|
||||
// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses
|
||||
// are overridden — the embedded interface panics for anything else, flagging unexpected calls.
|
||||
type fakeExternalProvider struct {
|
||||
external.Provider
|
||||
similarArtists model.Artists
|
||||
similarSongs model.MediaFiles
|
||||
}
|
||||
|
||||
func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) {
|
||||
return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil
|
||||
}
|
||||
|
||||
func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
|
||||
return f.similarSongs, nil
|
||||
}
|
||||
|
||||
// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are
|
||||
// active in e2e; the provider it hands back returns test-configured results.
|
||||
type fakeSonicLoader struct{ provider sonic.Provider }
|
||||
|
||||
func (f *fakeSonicLoader) PluginNames(capability string) []string {
|
||||
if capability == "SonicSimilarity" {
|
||||
return []string{"fake"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) {
|
||||
return f.provider, true
|
||||
}
|
||||
|
||||
// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the
|
||||
// agents.Song results; the real matcher resolves them back to seeded library tracks.
|
||||
type fakeSonicProvider struct {
|
||||
similar []sonic.SimilarResult
|
||||
path []sonic.SimilarResult
|
||||
}
|
||||
|
||||
func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
|
||||
return f.similar, nil
|
||||
}
|
||||
|
||||
func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
|
||||
return f.path, nil
|
||||
}
|
||||
|
||||
// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an
|
||||
// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile.
|
||||
func songAgent(title string) agents.Song {
|
||||
mfs, err := ds.MediaFile(ctx).GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, mf := range mfs {
|
||||
if mf.Title == title {
|
||||
return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}}
|
||||
}
|
||||
}
|
||||
Fail("song not found: " + title)
|
||||
return agents.Song{}
|
||||
}
|
||||
|
||||
// --- Spy/noop dependencies (shared ones live in tests/harness) ---
|
||||
|
||||
// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the
|
||||
// resolved ArtworkID and that resolution runs under an elevated (admin) context.
|
||||
type spyArtwork struct {
|
||||
lastID string
|
||||
lastCtx context.Context
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) {
|
||||
return nil, time.Time{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) {
|
||||
s.lastID = id
|
||||
s.lastCtx = c
|
||||
d := s.data
|
||||
if d == nil {
|
||||
d = []byte("IMG")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil
|
||||
}
|
||||
|
||||
var _ artwork.Artwork = &spyArtwork{}
|
||||
74
server/jellyfin/e2e/images_test.go
Normal file
74
server/jellyfin/e2e/images_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
64
server/jellyfin/e2e/multiuser_test.go
Normal file
64
server/jellyfin/e2e/multiuser_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
311
server/jellyfin/e2e/playlists_test.go
Normal file
311
server/jellyfin/e2e/playlists_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
31
server/jellyfin/e2e/routing_test.go
Normal file
31
server/jellyfin/e2e/routing_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
76
server/jellyfin/e2e/search_test.go
Normal file
76
server/jellyfin/e2e/search_test.go
Normal 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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
62
server/jellyfin/e2e/sessions_test.go
Normal file
62
server/jellyfin/e2e/sessions_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
134
server/jellyfin/e2e/similar_test.go
Normal file
134
server/jellyfin/e2e/similar_test.go
Normal 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!"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
49
server/jellyfin/e2e/smoke_test.go
Normal file
49
server/jellyfin/e2e/smoke_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
128
server/jellyfin/e2e/streaming_test.go
Normal file
128
server/jellyfin/e2e/streaming_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
55
server/jellyfin/e2e/system_test.go
Normal file
55
server/jellyfin/e2e/system_test.go
Normal 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
170
server/jellyfin/images.go
Normal 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
|
||||
}
|
||||
}
|
||||
394
server/jellyfin/images_test.go
Normal file
394
server/jellyfin/images_test.go
Normal 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
840
server/jellyfin/items.go
Normal 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
|
||||
}
|
||||
899
server/jellyfin/items_test.go
Normal file
899
server/jellyfin/items_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
25
server/jellyfin/jellyfin_suite_test.go
Normal file
25
server/jellyfin/jellyfin_suite_test.go
Normal 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)
|
||||
}
|
||||
44
server/jellyfin/library.go
Normal file
44
server/jellyfin/library.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
220
server/jellyfin/middlewares.go
Normal file
220
server/jellyfin/middlewares.go
Normal 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))
|
||||
})
|
||||
}
|
||||
382
server/jellyfin/middlewares_test.go
Normal file
382
server/jellyfin/middlewares_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
288
server/jellyfin/playlists.go
Normal file
288
server/jellyfin/playlists.go
Normal 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})
|
||||
}
|
||||
464
server/jellyfin/playlists_test.go
Normal file
464
server/jellyfin/playlists_test.go
Normal 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
Loading…
x
Reference in New Issue
Block a user