Merge branch 'master' into feat-podcast-ux-improvements/5420

This commit is contained in:
Jiho Andrew Lee 2026-09-22 16:20:45 +09:00
commit 6a1b201542
148 changed files with 5037 additions and 487 deletions

View file

@ -549,7 +549,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done

View file

@ -241,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
// methods, which the page block does not affect.
errNoArtistPage = errors.New("no artist image in Last.fm page")
)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
@ -267,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
return res, nil
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
return nil, errNoArtistPage
}
for _, attr := range n.Attr {
if attr.Key != "content" {

View file

@ -648,18 +648,41 @@ var _ = Describe("lastfmAgent", func() {
Expect(images).To(BeEmpty())
})
It("returns empty list if page has no meta tags", func() {
It("errors when the page has no meta tags", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(MatchError(errNoArtistPage))
})
It("errors when Last.fm serves a bot challenge page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(err).To(MatchError(errNoArtistPage))
Expect(images).To(BeEmpty())
})
It("does not park the agent: the failure is not a retry-later", func() {
// A RetryLaterError would cool down the agent's API-backed methods too.
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
})
It("returns error if API call fails", func() {
apiClient.Err = errors.New("api error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")

View file

@ -86,6 +86,7 @@ func runNavidrome(ctx context.Context) {
g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx))
g.Go(startPlaybackServer(ctx))
g.Go(startJellyfinDiscovery(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBAnalyzer(ctx))
@ -343,6 +344,18 @@ func startInsightsCollector(ctx context.Context) func() error {
}
}
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
func startJellyfinDiscovery(ctx context.Context) func() error {
return func() error {
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
log.Debug("Jellyfin auto-discovery is DISABLED")
return nil
}
CreateJellyfinDiscovery().Serve(ctx)
return nil
}
}
// startPlaybackServer starts the Navidrome playback server, if configured.
// It is responsible for the Jukebox functionality
func startPlaybackServer(ctx context.Context) func() error {

View file

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -80,7 +81,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
quickConnect := quickconnect.GetInstance()
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
return router
}
@ -137,7 +139,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
quickConnect := quickconnect.GetInstance()
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
return router
}
@ -170,6 +173,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
discovery := jellyfin.NewDiscovery(dataStore)
return discovery
}
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@ -251,7 +261,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
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.GetInstance, 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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, 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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View file

@ -36,6 +36,7 @@ var allProviders = wire.NewSet(
server.New,
subsonic.New,
jellyfin.New,
jellyfin.NewDiscovery,
nativeapi.New,
public.New,
persistence.New,
@ -108,6 +109,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
panic(wire.Build(
allProviders,
))
}
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,

View file

@ -235,6 +235,8 @@ type jellyfinOptions struct {
// 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
AutoDiscovery bool
QuickConnect bool
// 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.
@ -1087,6 +1089,8 @@ func setViperDefaults() {
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("jellyfin.enabled", false)
viper.SetDefault("jellyfin.servername", "")
viper.SetDefault("jellyfin.autodiscovery", false)
viper.SetDefault("jellyfin.quickconnect", true)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")

View file

@ -36,7 +36,7 @@ RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallFilter=setrlimit
SystemCallFilter=setrlimit mbind
SystemCallArchitectures=native
UMask=0066

View file

@ -168,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
if !entityExists(ctx, s.ds, artID) {
return nil, ErrUnavailable
}
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
return s.dangling(ctx, artID)
}
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {

View file

@ -2,18 +2,21 @@ package artwork
import (
"io/fs"
"net/netip"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/goleak"
@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) {
RunSpecs(t, "Artwork Suite")
}
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
var productionImageClient = remoteImageClient
// httptest servers listen on loopback, which the production client refuses.
var _ = BeforeSuite(func() {
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
})
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
type osDirFS struct{ fs.FS }

View file

@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(coverBytes))
})
It("treats a file-backed row pointing at a non-image file as dangling", func() {
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alni")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
secret := []byte("password=secret")
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alnic")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
// Older versions cached the raw bytes when the resize failed.
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
Expect(err).ToNot(HaveOccurred())
Expect(io.ReadAll(stream)).To(Equal(secret))
Expect(stream.Close()).To(Succeed())
Eventually(func(g Gomega) {
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(s.Cached).To(BeTrue())
_ = s.Close()
}).Should(Succeed())
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")

View file

@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- worker.Run(runCtx) }()
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
// Long enough for one retry (3-7s backoff, 5s poll tick).
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
cancel()
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
}

View file

@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0640); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}

View file

@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() {
Expect(got).To(Equal(data))
})
It("writes group-readable image files", func() {
tests.SkipOnWindows("uses Unix file permission bits")
data := []byte("jpeg-bytes")
h, _ := hashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
info, err := os.Stat(store.path(h, "image/jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640)))
})
It("is idempotent on duplicate writes and preserves the original content", func() {
data := []byte("dup")
h, _ := hashImage(bytes.NewReader(data))

View file

@ -572,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
// open failure says nothing about whether the image exists.
func resolveLocalFile(path, source string) (resolution, bool) {
if path == "" {
if path == "" || !model.IsImageFile(path) {
return resolution{}, false
}
f, err := os.Open(path)

View file

@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() {
Expect(res.refMtime).To(BeNumerically(">", 0))
})
It("never opens a local ExternalImageURL that is not an image file", func() {
folderRepo.result = nil // no grid tiles, so only the local file could produce a reader
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.sourcePath).ToNot(Equal(secretPath))
})
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces

View file

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/netguard"
"go.senan.xyz/taglib"
)
@ -37,7 +38,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
if !match || !model.IsImageFile(name) {
continue
}
f, err := libFS.Open(file)
@ -150,10 +151,18 @@ type readCloser struct {
io.Closer
}
// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach
// internal hosts. Shared so fetches reuse connections.
var remoteImageClient = httpclient.NewExternal(5 * time.Second)
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := httpclient.New(5 * time.Second)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
resp, err := hc.Do(req) //nolint:gosec
resp, err := remoteImageClient.Do(req)
if errors.Is(err, netguard.ErrPrivateAddress) {
// Retrying cannot change where the URL points: settle absent instead of tripping the breaker.
log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err)
return nil, "", model.ErrNotFound
}
if err != nil {
return nil, "", err
}

View file

@ -5,13 +5,87 @@ import (
"errors"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"strings"
"sync/atomic"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("fromURL", func() {
var (
hits atomic.Int32
target *httptest.Server
)
BeforeEach(func() {
hits.Store(0)
target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("image-bytes"))
}))
DeferCleanup(target.Close)
})
useClient := func(c *http.Client) {
prev := remoteImageClient
remoteImageClient = c
DeferCleanup(func() { remoteImageClient = prev })
}
fetch := func(rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
Expect(err).ToNot(HaveOccurred())
r, _, err := fromURL(GinkgoT().Context(), u)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused.
onlyLocalhostV4 := func() *http.Client {
return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32"))
}
DescribeTable("refuses private and loopback targets as a definitive miss",
func(rawURL string) {
useClient(productionImageClient)
u, _ := url.Parse(target.URL)
_, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port()))
Expect(err).To(MatchError(model.ErrNotFound))
Expect(hits.Load()).To(BeZero())
},
Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"),
Entry("localhost", "http://localhost:PORT/x"),
Entry("cloud metadata", "http://169.254.169.254/"),
Entry("IPv6 loopback", "http://[::1]/"),
)
It("refuses a redirect from an allowed host to a loopback address", func() {
useClient(onlyLocalhostV4())
redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound))
DeferCleanup(redirector.Close)
_, err := fetch(redirector.URL)
Expect(err).To(MatchError(model.ErrNotFound))
Expect(hits.Load()).To(BeZero())
})
It("fetches from an allowed address", func() {
useClient(onlyLocalhostV4())
Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes")))
})
})
var _ = Describe("fromExternalFile", func() {
It("opens a matching file via the library FS", func() {
fsys := fstest.MapFS{
@ -48,6 +122,18 @@ var _ = Describe("fromExternalFile", func() {
Expect(b).To(Equal([]byte("a")))
Expect(path).To(Equal("a/cover.jpg"))
})
It("skips a matching file that is not an image", func() {
fsys := fstest.MapFS{
"a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")},
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
Expect(path).To(Equal("a/cover.jpg"))
})
})
var _ = Describe("fromTag", func() {

View file

@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
user, _ := request.UserFrom(ctx)
if playerID != "" {
plr, err = p.ds.Player(ctx).Get(playerID)
if err == nil && plr.Client != client {
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
playerID = ""
}
}

View file

@ -61,8 +61,19 @@ var _ = Describe("Players", func() {
Expect(trc).To(BeNil())
})
It("does not reuse another user's player by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(Equal("123"))
Expect(p.UserId).To(Equal("userid"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("finds players by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@ -93,7 +104,7 @@ var _ = Describe("Players", func() {
})
It("finds player by ID and return its transcoding", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())

View file

@ -236,6 +236,24 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed())
m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
@ -1011,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ExternalImageURL).To(BeEmpty())
})
DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries",
func(imageURL, expected string) {
ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}})
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(expected))
},
Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")),
Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""),
)
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
// so we need exact matching for non-ASCII characters.
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {

View file

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
owner, _ := request.UserFrom(ctx)
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner)
continue
}
// Skip empty lines and extended info
@ -286,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher.
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
// Resolve to local absolute path
localPath, ok := resolveLocalPath(value, folder)
if !ok {
if !ok || !model.IsImageFile(localPath) {
return ""
}
// Validate path is within a known library
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
lib, ok := matcher.findLibrary(localPath)
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
return ""
}
return localPath

View file

@ -32,6 +32,7 @@ type Playlists interface {
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
@ -268,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
// InsertTracks adds tracks before the 1-based position pos; a position past the end appends.
func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
var count int
// Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under
// concurrent writers instead of waiting for the lock.
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var err error
count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos)
return err
})
return count, err
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
@ -302,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}

View file

@ -324,6 +324,43 @@ var _ = Describe("Playlists", func() {
})
})
Describe("InsertTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
})
It("inserts the tracks at the given position for the owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"}))
Expect(mockTracks.InsertPos).To(Equal(3))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockTracks.AddedIds).To(BeEmpty())
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo

View file

@ -0,0 +1,203 @@
// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and
// an already signed-in user approves it, so the client can sign in without a password.
package quickconnect
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/singleton"
)
const timeout = 10 * time.Minute
// Initiate is unauthenticated, so the number of live requests must be bounded.
const maxPending = 1000
var (
ErrAlreadyAuthorized = errors.New("quick connect request already authorized")
ErrTooManyRequests = errors.New("too many pending quick connect requests")
)
type Device struct {
ID string
Name string
App string
AppVersion string
}
type Request struct {
Device Device
Secret string
Code string
DateAdded time.Time
UserID string
}
func (r Request) Authorized() bool {
return r.UserID != ""
}
type QuickConnect interface {
Initiate(device Device) (Request, error)
Status(secret string) (Request, error)
// Lookup returns a request still waiting for approval.
Lookup(code string) (Request, error)
Authorize(code, userID string) (Request, error)
// Redeem returns the id of the user who approved the request. It succeeds only once per secret.
Redeem(secret string) (string, error)
}
type entry struct {
Request
expiresAt time.Time
}
type quickConnect struct {
mu sync.Mutex
bySecret map[string]*entry
byCode map[string]*entry
}
// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests.
func GetInstance() QuickConnect {
return singleton.GetInstance(newStore)
}
func New() QuickConnect {
return newStore()
}
func newStore() *quickConnect {
return &quickConnect{
bySecret: map[string]*entry{},
byCode: map[string]*entry{},
}
}
// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API.
func Enabled() bool {
return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect
}
func (qc *quickConnect) Initiate(device Device) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
qc.expire()
if len(qc.bySecret) >= maxPending {
return Request{}, ErrTooManyRequests
}
// The fields may be substrings of a much larger header; copy them so the header isn't retained.
device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name),
App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)}
now := time.Now()
e := &entry{
Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now},
expiresAt: now.Add(timeout),
}
qc.bySecret[e.Secret] = e
qc.byCode[e.Code] = e
return e.Request, nil
}
func (qc *quickConnect) Status(secret string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Lookup(code string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Authorize(code, userID string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
e.UserID = userID
e.expiresAt = time.Now().Add(timeout)
return e.Request, nil
}
func (qc *quickConnect) Redeem(secret string) (string, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil || !e.Authorized() {
return "", model.ErrNotFound
}
qc.remove(e)
return e.UserID, nil
}
func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) {
qc.expire()
e, ok := index[key]
if !ok {
return nil, model.ErrNotFound
}
return e, nil
}
func (qc *quickConnect) findPending(code string) (*entry, error) {
e, err := qc.find(qc.byCode, normalizeCode(code))
if err == nil && e.Authorized() {
return nil, ErrAlreadyAuthorized
}
return e, err
}
func (qc *quickConnect) expire() {
now := time.Now()
for _, e := range qc.bySecret {
if !now.Before(e.expiresAt) {
qc.remove(e)
}
}
}
func (qc *quickConnect) remove(e *entry) {
delete(qc.bySecret, e.Secret)
delete(qc.byCode, e.Code)
}
func (qc *quickConnect) newCode() string {
for {
code := fmt.Sprintf("%06d", random.Int64N(900000)+100000)
if _, taken := qc.byCode[code]; !taken {
return code
}
}
}
func newSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Users often type the code in groups ("123 456").
func normalizeCode(code string) string {
return strings.Join(strings.Fields(code), "")
}

View file

@ -0,0 +1,17 @@
package quickconnect
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestQuickConnect(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "QuickConnect Suite")
}

View file

@ -0,0 +1,190 @@
package quickconnect
import (
"testing"
"testing/synctest"
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}
var _ = Describe("QuickConnect", func() {
var qc QuickConnect
BeforeEach(func() {
qc = New()
})
Describe("Initiate", func() {
It("creates a pending request with a 6-digit code and a random secret", func() {
req, err := qc.Initiate(device)
Expect(err).ToNot(HaveOccurred())
Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`))
Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`))
Expect(req.Device).To(Equal(device))
Expect(req.DateAdded).ToNot(BeZero())
Expect(req.Authorized()).To(BeFalse())
})
It("never gives two live requests the same code or secret", func() {
codes := map[string]bool{}
secrets := map[string]bool{}
for range 500 {
req, err := qc.Initiate(device)
Expect(err).ToNot(HaveOccurred())
codes[req.Code] = true
secrets[req.Secret] = true
}
Expect(codes).To(HaveLen(500))
Expect(secrets).To(HaveLen(500))
})
It("refuses new requests when too many are pending", func() {
for range maxPending {
_, err := qc.Initiate(device)
Expect(err).ToNot(HaveOccurred())
}
_, err := qc.Initiate(device)
Expect(err).To(MatchError(ErrTooManyRequests))
})
})
Describe("Status", func() {
It("returns the request for a known secret", func() {
req, _ := qc.Initiate(device)
got, err := qc.Status(req.Secret)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(req))
})
It("returns ErrNotFound for an unknown secret", func() {
_, err := qc.Status("nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Lookup", func() {
It("finds a request by code, ignoring spaces", func() {
req, _ := qc.Initiate(device)
got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:])
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(req))
})
It("returns ErrNotFound for an unknown code", func() {
_, err := qc.Lookup("000000")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("refuses a request that is already authorized", func() {
req, _ := qc.Initiate(device)
_, _ = qc.Authorize(req.Code, "user-1")
_, err := qc.Lookup(req.Code)
Expect(err).To(MatchError(ErrAlreadyAuthorized))
})
})
Describe("Authorize", func() {
It("marks the request as authorized by the user", func() {
req, _ := qc.Initiate(device)
got, err := qc.Authorize(" "+req.Code+" ", "user-1")
Expect(err).ToNot(HaveOccurred())
Expect(got.Authorized()).To(BeTrue())
Expect(got.UserID).To(Equal("user-1"))
status, _ := qc.Status(req.Secret)
Expect(status.Authorized()).To(BeTrue())
})
It("refuses a request that is already authorized", func() {
req, _ := qc.Initiate(device)
_, _ = qc.Authorize(req.Code, "user-1")
_, err := qc.Authorize(req.Code, "user-2")
Expect(err).To(MatchError(ErrAlreadyAuthorized))
})
It("returns ErrNotFound for an unknown code", func() {
_, err := qc.Authorize("000000", "user-1")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Redeem", func() {
It("returns the approving user once, then forgets the request", func() {
req, _ := qc.Initiate(device)
_, _ = qc.Authorize(req.Code, "user-1")
userID, err := qc.Redeem(req.Secret)
Expect(err).ToNot(HaveOccurred())
Expect(userID).To(Equal("user-1"))
_, err = qc.Redeem(req.Secret)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Status(req.Secret)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Lookup(req.Code)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("refuses a request that is not authorized yet", func() {
req, _ := qc.Initiate(device)
_, err := qc.Redeem(req.Secret)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Status(req.Secret)
Expect(err).ToNot(HaveOccurred())
})
})
})
// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give.
func TestPendingRequestExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
qc := New()
req, _ := qc.Initiate(device)
time.Sleep(timeout - time.Second)
_, err := qc.Status(req.Secret)
g.Expect(err).ToNot(HaveOccurred())
time.Sleep(2 * time.Second)
_, err = qc.Status(req.Secret)
g.Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Authorize(req.Code, "user-1")
g.Expect(err).To(MatchError(model.ErrNotFound))
})
}
func TestAuthorizeExtendsExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
qc := New()
req, _ := qc.Initiate(device)
time.Sleep(timeout - time.Second)
_, err := qc.Authorize(req.Code, "user-1")
g.Expect(err).ToNot(HaveOccurred())
time.Sleep(timeout - time.Second)
userID, err := qc.Redeem(req.Secret)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(userID).To(Equal("user-1"))
})
}
func TestExpiredRequestsFreeCapacity(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
qc := New()
for range maxPending {
_, _ = qc.Initiate(device)
}
time.Sleep(timeout + time.Second)
_, err := qc.Initiate(device)
g.Expect(err).ToNot(HaveOccurred())
})
}

View file

@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/navidrome/navidrome/utils/slice"
)
const (
@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
return nil
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) {
// The cache is process-global, so it holds every user's playback, across all libraries.
res := p.playMap.Values()
if user, ok := request.UserFrom(ctx); ok {
res = slice.Filter(res, func(s PlaybackSession) bool {
return user.HasLibraryAccess(s.MediaFile.LibraryID)
})
}
slices.SortFunc(res, func(a, b PlaybackSession) int {
return b.Start.Compare(a.Start)
})

View file

@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
fake = &fakeScrobbler{Authorized: true}
@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() {
track = model.MediaFile{
ID: "123",
LibraryID: 1,
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() {
Expect(playing[1].Username).To(Equal("user-1"))
Expect(playing[1].MediaFile.ID).To(Equal("123"))
})
It("hides sessions playing from libraries the caller cannot access", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
)
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only")
})
It("shows every session to an admin", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
)
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true})
playing, err := tracker.GetNowPlaying(adminCtx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].MediaFile.ID).To(Equal("789"))
})
})
Describe("Expiration events", func() {

View file

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
)
@ -32,6 +33,7 @@ var Set = wire.NewSet(
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
quickconnect.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)

View file

@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool {
}
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return nil
}
return c.childPlaylistRefs(conjunction.ChildPlaylistIds)
}
func (c Criteria) ChildPlaylistPaths() []string {
return c.childPlaylistRefs(conjunction.ChildPlaylistPaths)
}
func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string {
parent, ok := c.Expression.(conjunction)
if !ok {
return nil
}
ids := parent.ChildPlaylistIds()
slices.Sort(ids)
return slices.Compact(ids)
refs := extract(parent)
slices.Sort(refs)
return slices.Compact(refs)
}
func (c Criteria) MarshalJSON() ([]byte, error) {

View file

@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() {
Context("with child playlists", func() {
var (
topLevelInPlaylistID string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
topLevelInPlaylistID string
topLevelInPlaylistPath string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
nestedAnyNotInPlaylistPath string
)
BeforeEach(func() {
topLevelInPlaylistID = uuid.NewString()
topLevelInPlaylistPath = "./test.nsp"
topLevelNotInPlaylistID = uuid.NewString()
nestedAnyInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u"
nestedAllInPlaylistID = uuid.NewString()
nestedAllNotInPlaylistID = uuid.NewString()
@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() {
goObj = Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
InPlaylist{"path": topLevelInPlaylistPath},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
NotInPlaylist{"path": nestedAnyNotInPlaylistPath},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
@ -359,6 +365,18 @@ var _ = Describe("Criteria", func() {
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts all child smart playlist paths from expression criteria", func() {
paths := goObj.ChildPlaylistPaths()
gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath))
})
It("ignores empty child playlist paths", func() {
c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}}
gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty())
})
It("ignores empty child playlist ids", func() {
c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}}
gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty())
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
goObj = Criteria{
Expression: Any{

View file

@ -1,8 +1,9 @@
package criteria
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively
type conjunction interface {
ChildPlaylistIds() []string
ChildPlaylistPaths() []string
}
type (
@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
}
func (all All) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(all)
}
func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") }
func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") }
type (
Any []Expression
@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
}
func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") }
func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") }
type Is map[string]any
type Eq = Is
@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) {
func (ip IsPresent) fields() map[string]any { return ip }
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool
func extractPlaylistField(inputRule any, field string) (values []string) {
switch rule := inputRule.(type) {
case Any:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
values = append(values, extractPlaylistField(rules, field)...)
}
case All:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
values = append(values, extractPlaylistField(rules, field)...)
}
case InPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
}
case NotInPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
case InPlaylist, NotInPlaylist:
if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" {
values = append(values, value)
}
}
return
}

View file

@ -2,12 +2,16 @@ package model
import (
"iter"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/criteria"
)
@ -137,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string {
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
}
// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths.
func (pls Playlist) NormalizedRules() *criteria.Criteria {
if pls.Rules == nil || pls.Rules.Expression == nil {
return pls.Rules
}
rules := *pls.Rules
rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path)
return &rules
}
func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression {
switch rule := inputRule.(type) {
case criteria.Any:
anyCriteria := make(criteria.Any, len(rule))
for i, rules := range rule {
anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
}
return anyCriteria
case criteria.All:
allCriteria := make(criteria.All, len(rule))
for i, rules := range rule {
allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
}
return allCriteria
case criteria.InPlaylist:
return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
case criteria.NotInPlaylist:
return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
}
return inputRule
}
func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any {
path, ok := rule["path"].(string)
if !ok || path == "" {
return rule
}
// References use forward slashes to stay portable, while Playlist.Path is OS-native.
path = filepath.FromSlash(path)
switch {
case isAbsPlaylistRef(path):
path = filepath.Clean(path)
case referencingPlaylistPath != "":
path = filepath.Join(filepath.Dir(referencingPlaylistPath), path)
default:
log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path)
}
normalized := maps.Clone(rule)
normalized["path"] = path
return normalized
}
// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute.
func isAbsPlaylistRef(path string) bool {
return filepath.IsAbs(path) || os.IsPathSeparator(path[0])
}
type Playlists []Playlist
type PlaylistCursor iter.Seq2[Playlist, error]
@ -184,6 +248,7 @@ type PlaylistTrackRepository interface {
GetAlbumIDs(options ...QueryOptions) ([]string, error)
GetMediaFileIDs(options ...QueryOptions) ([]string, error)
Add(mediaFileIds []string) (int, error)
Insert(mediaFileIds []string, pos int) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)
AddDiscs(discs []DiscID) (int, error)

View file

@ -1,6 +1,7 @@
package model_test
import (
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -88,4 +89,102 @@ var _ = Describe("Playlist", func() {
Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse())
})
})
Describe("NormalizedRules()", func() {
// absPath builds an OS-native absolute path so these specs also run on Windows.
absPath := func(parts ...string) string {
abs, err := filepath.Abs(filepath.Join(parts...))
Expect(err).ToNot(HaveOccurred())
return abs
}
normalize := func(pls model.Playlist) criteria.Expression {
return pls.NormalizedRules().Expression
}
It("resolves relative references against the playlist folder", func() {
pls := model.Playlist{
Path: absPath("test", "nested", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{
criteria.InPlaylist{"path": "../up.m3u"},
criteria.NotInPlaylist{"path": "./sibling.nsp"},
criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}},
}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": absPath("test", "up.m3u")},
criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")},
criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}},
}))
})
It("cleans absolute references", func() {
dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp"
pls := model.Playlist{
Path: absPath("test", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.NotInPlaylist{"path": absPath("music", "child.nsp")},
}))
})
It("treats a leading slash as absolute on every OS", func() {
pls := model.Playlist{
Path: absPath("test", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")},
}))
})
It("leaves empty paths and id references untouched", func() {
pls := model.Playlist{
Path: absPath("test", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{
criteria.InPlaylist{"path": ""},
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
criteria.Eq{"artist": "Bob Dealin"},
}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": ""},
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
criteria.Eq{"artist": "Bob Dealin"},
}))
})
It("skips relative references when the playlist has no path", func() {
pls := model.Playlist{
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")},
}))
})
It("preserves every other criteria field", func() {
rules := criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}},
Sort: "title",
Order: "desc",
Limit: 10,
LimitPercent: 25,
Offset: 5,
RefreshDelay: 3 * time.Hour,
}
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules}
normalized := *pls.NormalizedRules()
normalized.Expression = rules.Expression
Expect(normalized).To(Equal(rules))
})
It("does not mutate the original playlist rules", func() {
original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}}
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}}
_ = pls.NormalizedRules()
Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"}))
})
})
})

View file

@ -13,6 +13,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"golang.org/x/text/unicode/norm"
)
type smartPlaylistJoinType int
@ -344,11 +345,15 @@ func startOfPeriod(numDays int64, from time.Time) string {
}
func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
playlistID, ok := values["id"].(string)
if !ok {
return nil, errors.New("playlist id not given")
var condition squirrel.Sqlizer
if playlistId, ok := values["id"].(string); ok && playlistId != "" {
condition = squirrel.Eq{"pl.playlist_id": playlistId}
} else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" {
condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)}
} else {
return nil, errors.New("playlist id or path not given")
}
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
filters := squirrel.And{condition}
if !c.owner.IsAdmin {
if c.owner.ID == "" {
filters = append(filters, squirrel.Eq{"playlist.public": 1})
@ -373,6 +378,18 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
}
// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD.
func pathVariants(path string) []string {
variants := []string{path}
if alt := norm.NFC.String(path); alt != path {
variants = append(variants, alt)
}
if alt := norm.NFD.String(path); alt != path {
variants = append(variants, alt)
}
return variants
}
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole {
return roleCond{role: info.Name(), cond: cond, not: negate}

View file

@ -47,7 +47,10 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1),
Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1),
Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1),
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3),
Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true),
@ -268,6 +271,13 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
})
It("returns an error when inPlaylist has empty path", func() {
_, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}},
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where()
Expect(err).To(MatchError(ContainSubstring("playlist id or path not given")))
})
It("returns an error for a range over a tag/role field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))

View file

@ -27,6 +27,14 @@ type dbFolder struct {
ImageFiles string `structs:"-" json:"-"`
}
// String guards the promoted Folder.String(), which would dereference a nil Folder.
func (f dbFolder) String() string {
if f.Folder == nil {
return "<nil>"
}
return f.Folder.String()
}
func (f *dbFolder) PostScan() error {
var err error
if f.ImageFiles != "" {

View file

@ -310,6 +310,13 @@ var _ = Describe("FolderRepository", func() {
})
})
Describe("dbFolder.String", func() {
It("does not dereference a nil Folder", func() {
Expect(fmt.Sprint(dbFolder{})).To(Equal("<nil>"))
Expect(fmt.Sprint(&dbFolder{})).To(Equal("<nil>"))
})
})
Describe("wrapFolderCursor", func() {
It("does not panic when the cursor yields a dbFolder with nil Folder", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:

View file

@ -38,6 +38,14 @@ type dbMediaFile struct {
RgTrackPeak *float64 `structs:"-" json:"-"`
}
// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile.
func (m dbMediaFile) String() string {
if m.MediaFile == nil {
return "<nil>"
}
return m.MediaFile.String()
}
func (m *dbMediaFile) PostScan() error {
m.RGTrackGain = m.RgTrackGain
m.RGTrackPeak = m.RgTrackPeak

View file

@ -1203,6 +1203,13 @@ var _ = Describe("MediaRepository", func() {
})
})
Describe("dbMediaFile.String", func() {
It("does not dereference a nil MediaFile", func() {
Expect(fmt.Sprint(dbMediaFile{})).To(Equal("<nil>"))
Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("<nil>"))
})
})
Describe("wrapMediaFileCursor", func() {
It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:

View file

@ -204,6 +204,28 @@ func p(path string) string {
return filepath.FromSlash(path)
}
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
// specs can assert that a query filters by library. Cleans itself up after the spec.
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
db := GetDBXBuilder()
lib := model.Library{Name: name + " Library", Path: "/" + name}
lr := NewLibraryRepository(adminCtx, db)
Expect(lr.Put(&lib)).To(Succeed())
user := createUserWithLibraries(name+"-restricted", []int{1})
ur := NewUserRepository(adminCtx, db)
Expect(ur.Put(&user)).To(Succeed())
Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed())
DeferCleanup(func() {
_ = NewUserRepository(adminCtx, db).Delete(user.ID)
_ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID})
})
return adminCtx, lib, user
}
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())

View file

@ -126,7 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) {
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
}
return r.put(t.ID, t)
return r.put("", t) // Save only creates; edits go through the owner-scoped Update
}
func (r *playerRepository) Update(id string, entity any, cols ...string) error {

View file

@ -288,6 +288,27 @@ var _ = Describe("PlayerRepository", func() {
Expect(*stored).To(Equal(adminPlayer1))
})
It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() {
spoofed := model.Player{
ID: adminPlayer1.ID,
Name: "HIJACKED",
UserId: regularUser.ID,
ReportRealPath: true,
}
id, err := regularRepo.Save(&spoofed)
Expect(err).To(BeNil())
Expect(id).ToNot(Equal(adminPlayer1.ID))
stored, err := adminRepo.Get(adminPlayer1.ID)
Expect(err).To(BeNil())
Expect(*stored).To(Equal(adminPlayer1))
created, err := adminRepo.Get(id)
Expect(err).To(BeNil())
Expect(created.UserId).To(Equal(regularUser.ID))
})
It("does not let a regular user reassign their own player to another user", func() {
// Owner updates their own player but tries to give it away to the admin. The update
// succeeds for the other fields, but user_id is never written, so ownership stays put.

View file

@ -13,6 +13,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
return err
}
return r.addTracks(playlistId, 1, mediaFileIds)
_, err = r.addTracks(playlistId, 1, mediaFileIds)
return err
}
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
// the library check: every caller, including a full replace through Put, goes through it.
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) {
mediaFileIds, err := r.keepAccessible(mediaFileIds)
if err != nil {
return 0, err
}
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
// Add new tracks, chunk by chunk
pos := startingPos
@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media
ins = ins.Values(playlistId, t, pos)
pos++
}
_, err := r.executeSQL(ins)
if err != nil {
return err
if _, err := r.executeSQL(ins); err != nil {
return 0, err
}
}
r.enqueueCoverRebuild(playlistId)
return r.refreshCounters(&model.Playlist{ID: playlistId})
return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId})
}
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) {
if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) {
return mediaFileIds, nil
}
accessible := make(map[string]struct{}, len(mediaFileIds))
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
var found []string
if err := r.queryAllSlice(sq, &found); err != nil {
return nil, err
}
for _, id := range found {
accessible[id] = struct{}{}
}
}
return slice.Filter(mediaFileIds, func(id string) bool {
_, ok := accessible[id]
return ok
}), nil
}
// refreshCounters updates total playlist duration, size and count

View file

@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
query := Select().
LeftJoin("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
return r.count(query, r.parseRestOptions(r.ctx, options...))
}
@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) {
).
Join("media_file f on f.id = media_file_id").
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
sel = r.applyLibraryFilter(sel, "f")
var trk dbPlaylistTrack
err := r.queryOne(sel, &trk)
return trk.PlaylistTrack, err
@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
query := r.newSelect(options...).Columns("distinct mf.album_id").
Join("media_file mf on mf.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "mf")
var ids []string
err := r.queryAllSlice(query, &ids)
if err != nil {
@ -187,12 +190,40 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
// Get next pos (ID) in playlist
sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId})
var res struct{ Max sql.NullInt32 }
err := r.queryOne(sq, &res)
if err != nil {
if err := r.queryOne(sq, &res); err != nil {
return 0, err
}
return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
}
// Insert adds tracks before the 1-based position pos, shifting the following entries down; a
// position past the end appends. Callers must run it in a transaction.
func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) {
if len(mediaFileIds) == 0 {
return 0, nil
}
pos = max(pos, 1)
n := len(mediaFileIds)
// Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index.
_, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos))
if err != nil {
return 0, err
}
res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId))
if err != nil {
return 0, err
}
if res == 0 {
return r.Add(mediaFileIds)
}
inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
if err != nil || inserted == n {
return inserted, err
}
// The shift above reserved a slot per requested id, so ids dropped by the library filter
// leave a hole. Close it.
return inserted, r.playlistRepo.renumber(r.playlistId)
}
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
@ -247,8 +278,18 @@ func (r *playlistTrackRepository) DeleteAll() error {
return r.playlistRepo.renumber(r.playlistId)
}
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped
// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
var res struct{ Max sql.NullInt32 }
if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil {
return err
}
last := int(res.Max.Int32)
if pos < 1 || pos > last {
return model.ErrNotFound
}
newPos = min(max(newPos, 1), last)
if pos == newPos {
return nil
}

View file

@ -1,11 +1,13 @@
package persistence
import (
"context"
"strconv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -78,6 +80,105 @@ var _ = Describe("PlaylistTrackRepository", func() {
})
})
Describe("Insert", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
})
order := func() []string {
ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
return ids
}
DescribeTable("inserts before a 1-based position, keeping the new tracks' order",
func(pos int, want func() []string) {
Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2))
Expect(order()).To(Equal(want()))
Expect(tracks.CountAll()).To(Equal(int64(4)))
},
Entry("in the middle", 2, func() []string {
return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID}
}),
Entry("at the start, for zero or less", 0, func() []string {
return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID}
}),
Entry("at the end, past the last position", 9, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID}
}),
)
It("renumbers positions contiguously", func() {
Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1))
all, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
})
})
Describe("Reorder", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3))
})
rows := func() ([]string, []string) {
all, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
var ids, songs []string
for _, t := range all {
ids = append(ids, t.ID)
songs = append(songs, t.MediaFileID)
}
return ids, songs
}
DescribeTable("clamps the destination to the playlist",
func(newPos int, want func() []string) {
Expect(tracks.Reorder(1, newPos)).To(Succeed())
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal(want()))
},
Entry("past the end moves to the end", 9, func() []string {
return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID}
}),
Entry("below 1 stays first", -4, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}
}),
)
DescribeTable("rejects a source position outside the playlist, leaving rows untouched",
func(pos int) {
Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound))
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}))
},
Entry("past the end", 4),
Entry("zero", 0),
)
})
Describe("Delete", func() {
var tracks model.PlaylistTrackRepository
const numTracks = deleteChunkSize*2 + 1
@ -122,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() {
Expect(tracks.CountAll()).To(BeZero())
})
})
Describe("library access", func() {
var otherLib model.Library
var restrictedUser model.User
var adminCtx, userCtx context.Context
var userTracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
adminCtx, otherLib, restrictedUser = restrictedFixture("pls")
userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
db := GetDBXBuilder()
adminMr := NewMediaFileRepository(adminCtx, db)
Expect(adminMr.Put(&model.MediaFile{
ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album",
Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist",
})).To(Succeed())
DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") })
adminPls := NewPlaylistRepository(adminCtx, db)
pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true}
Expect(adminPls.Put(&pls)).To(Succeed())
plsID = pls.ID
DeferCleanup(func() { _ = adminPls.Delete(plsID) })
Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2))
userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false)
})
It("Read does not return a track outside the user's libraries", func() {
_, err := userTracks.Read("2")
Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access")
})
It("Read still returns a track inside the user's libraries", func() {
trk, err := userTracks.Read("1")
Expect(err).ToNot(HaveOccurred())
Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID))
})
It("Count excludes tracks outside the user's libraries", func() {
Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing")
})
It("GetAlbumIDs excludes albums outside the user's libraries", func() {
Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album"))
})
Describe("Add", func() {
var ownTracks model.PlaylistTrackRepository
BeforeEach(func() {
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName}
Expect(userPls.Put(&own)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) })
ownTracks = userPls.Tracks(own.ID, false)
})
It("drops ids outside the user's libraries", func() {
Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID))
})
It("drops them when reached through AddAlbums", func() {
Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero())
})
It("drops them when reached through Insert", func() {
Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1))
Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID}))
trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}),
"positions must stay contiguous when an id is dropped")
})
})
Describe("Put", func() {
storedIDs := func(id string) []string {
ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs()
Expect(err).ToNot(HaveOccurred())
return ids
}
put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string {
pls.OwnerID = owner.ID
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) })
return pls.ID
}
It("drops ids outside the user's libraries when creating a playlist", func() {
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track")
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID}))
})
It("drops them when replacing the tracks of an existing playlist", func() {
pls := &model.Playlist{Name: "Replaced"}
put(userCtx, restrictedUser, pls, songDayInALife.ID)
put(userCtx, restrictedUser, pls, "pls-otherlib-track")
Expect(storedIDs(pls.ID)).To(BeEmpty())
})
It("does not count a dropped id, so it cannot be told apart from an unknown one", func() {
hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track")
unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track")
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
h, err := userPls.Get(hidden)
Expect(err).ToNot(HaveOccurred())
u, err := userPls.Get(unknown)
Expect(err).ToNot(HaveOccurred())
Expect(h.SongCount).To(Equal(u.SongCount))
Expect(h.Duration).To(Equal(u.Duration))
Expect(h.Size).To(Equal(u.Size))
})
It("keeps order and duplicates of the accessible ids", func() {
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"},
songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID)
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID}))
})
It("keeps every id when run as an admin, as the scanner's playlist sync does", func() {
id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track")
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"}))
})
})
It("still shows everything to an admin", func() {
adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false)
Expect(adminTracks.Count()).To(Equal(int64(2)))
_, err := adminTracks.Read("2")
Expect(err).ToNot(HaveOccurred())
})
})
})

View file

@ -1,11 +1,14 @@
package persistence
import (
"slices"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated
@ -16,6 +19,17 @@ import (
// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return r.refreshSmartPlaylistTree(pls, map[string]struct{}{})
}
// The visited set stops playlists that reference each other from recursing forever.
func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool {
if _, seen := visited[pls.ID]; seen {
log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID)
return false
}
visited[pls.ID] = struct{}{}
usr := loggedUser(r.ctx)
if !r.shouldRefreshSmartPlaylist(pls, usr) {
return false
@ -30,9 +44,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return false
}
rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr))
if !r.refreshChildPlaylists(pls, rulesSQL) {
if !r.refreshChildPlaylists(pls, rulesSQL, visited) {
return false
}
@ -89,28 +103,47 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr
// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria.
// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort.
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool {
childPlaylistIds := rulesSQL.ChildPlaylistIds()
if len(childPlaylistIds) == 0 {
childPlaylistPaths := rulesSQL.ChildPlaylistPaths()
if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 {
return true
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
var conditions Or
if len(childPlaylistIds) > 0 {
conditions = append(conditions, Eq{"playlist.id": childPlaylistIds})
}
if len(childPlaylistPaths) > 0 {
lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...)
conditions = append(conditions, Eq{"playlist.path": lookupPaths})
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions})
if err != nil {
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err)
return false
}
found := make(map[string]struct{}, len(childPlaylists))
found := make(map[string]struct{}, len(childPlaylists)*2)
for i := range childPlaylists {
found[childPlaylists[i].ID] = struct{}{}
r.refreshSmartPlaylist(&childPlaylists[i])
if childPlaylists[i].Path != "" {
found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{}
}
r.refreshSmartPlaylistTree(&childPlaylists[i], visited)
}
for _, id := range childPlaylistIds {
if _, ok := found[id]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
}
}
for _, path := range childPlaylistPaths {
if _, ok := found[norm.NFC.String(path)]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID)
}
}
return true
}

View file

@ -1,6 +1,7 @@
package persistence
import (
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -124,13 +125,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
childRules = &criteria.Criteria{
Expression: criteria.All{
criteria.Eq{"artist": "シートベルツ"},
},
}
nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPathPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.Any{
criteria.InPlaylist{"id": nestedPls.ID},
criteria.InPlaylist{"path": nestedPathPls.Path},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
@ -148,17 +159,88 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
// Nested playlists should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
It("does not recurse forever when two smart playlists reference each other", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(&plsA)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(plsA.ID) })
plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}},
}}
Expect(repo.Put(&plsB)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(plsB.ID) })
plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}}
Expect(repo.Put(&plsA)).To(Succeed())
_, err := repo.GetWithTracks(plsA.ID, true, false)
Expect(err).ToNot(HaveOccurred())
})
It("does not treat an empty path as a reference to every playlist without a path", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(&bystander)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(bystander.ID) })
parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": ""}},
}}
Expect(repo.Put(&parent)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parent.ID) })
_, err := repo.GetWithTracks(parent.ID, true, false)
Expect(err).ToNot(HaveOccurred())
reloaded, err := repo.Get(bystander.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.EvaluatedAt).To(BeNil())
})
It("matches a child path stored in a different Unicode normalization form", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(&child)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(child.ID) })
parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}},
}}
Expect(repo.Put(&parent)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parent.ID) })
pls, err := repo.GetWithTracks(parent.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
})
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour

View file

@ -378,8 +378,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S
for row, err := range cursor {
m := toModel(row)
if m == nil {
// Don't format row: its String() derefs the nil model (golang/go#81238).
var zero T
yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err))
yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err))
return
}
if !yield(*m, err) || err != nil {

View file

@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
sq := r.newSelect().Columns(r.tableName + ".*")
sq = r.withAnnotation(sq, idField)
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
sq = r.applyLibraryFilter(sq)
var mfs dbMediaFiles // TODO Decouple from media_file
err := r.queryAll(sq, &mfs)
if err != nil {

View file

@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() {
Expect(mr.GetBookmarks()).To(BeEmpty())
})
})
Describe("library access", func() {
var otherLib model.Library
var restrictedUser model.User
var adminCtx context.Context
var userMr model.MediaFileRepository
BeforeEach(func() {
adminCtx, otherLib, restrictedUser = restrictedFixture("bmk")
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.Put(&model.MediaFile{
ID: "bmk-otherlib-track", LibraryID: otherLib.ID,
Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked",
})).To(Succeed())
DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") })
userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
userMr = NewMediaFileRepository(userCtx, GetDBXBuilder())
})
It("does not return bookmarks for tracks outside the user's libraries", func() {
Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed())
Expect(userMr.GetBookmarks()).To(BeEmpty())
})
It("still returns the bookmark for an admin", func() {
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed())
DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") })
bms, err := adminMr.GetBookmarks()
Expect(err).ToNot(HaveOccurred())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track"))
})
It("keeps returning bookmarks for tracks inside the user's libraries", func() {
Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed())
DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) })
bms, err := userMr.GetBookmarks()
Expect(err).ToNot(HaveOccurred())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
})
})
})

View file

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/netguard"
)
const (
@ -201,7 +202,7 @@ func isPrivateOrLoopback(hostname string) bool {
if ip == nil {
return false
}
return isPrivateIP(ip)
return netguard.IsPrivateIP(ip)
}
// Verify interface implementation

View file

@ -4,6 +4,8 @@ import (
"fmt"
"net"
"slices"
"github.com/navidrome/navidrome/utils/netguard"
)
// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a
@ -17,7 +19,7 @@ func checkPrivateDial(requiredHosts []string, address string) error {
return err
}
ip := net.ParseIP(host)
if ip == nil || !isPrivateIP(ip) {
if ip == nil || !netguard.IsPrivateIP(ip) {
return nil
}
for _, entry := range requiredHosts {
@ -51,7 +53,3 @@ func ipMatchesEntry(entry string, ip net.IP) bool {
}
return false
}
func isPrivateIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}

View file

@ -689,11 +689,12 @@ var _ = Describe("TaskQueueService", func() {
copy(times, dispatchTimes)
mu.Unlock()
// Consecutive dispatches should have at least ~160ms gap (80% of 200ms)
// Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps.
for i := 1; i < len(times); i++ {
gap := times[i].Sub(times[i-1])
Expect(gap).To(BeNumerically(">=", 160*time.Millisecond),
fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap))
offset := times[i].Sub(times[0])
minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond
Expect(offset).To(BeNumerically(">=", minOffset),
fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset))
}
})
})

View file

@ -94,7 +94,7 @@
"download": "Herunterladen",
"info": "Mehr Informationen",
"share": "Freigabe erstellen",
"refresh": ""
"refresh": "Metadaten aktualisieren"
},
"lists": {
"all": "Alle",
@ -157,12 +157,12 @@
"token": "Token",
"lastAccessAt": "Letzter Zugriff am",
"libraries": "Bibliotheken",
"scrobbleFilter": ""
"scrobbleFilter": "Scrobble-Filter"
},
"helperTexts": {
"name": "Die Änderung wird erst nach dem nächsten Login gültig",
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken",
"scrobbleFilter": ""
"scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt."
},
"notifications": {
"created": "Benutzer erstellt",
@ -177,7 +177,7 @@
},
"validation": {
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein",
"invalidScrobbleFilter": ""
"invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt."
}
},
"player": {
@ -202,7 +202,7 @@
"command": "Befehl"
},
"choices": {
"noDefaultBitRate": ""
"noDefaultBitRate": "Keine"
}
},
"playlist": {
@ -398,6 +398,7 @@
"invalidJson": "Konfiguration muss valides JSON sein"
},
"messages": {
"idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.",
"configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.",
"clickPermissions": "Berechtigung anklicken für mehr Details",
"noConfig": "Keine Konfiguration gesetzt",
@ -411,8 +412,7 @@
"requiredHosts": "Benötigte Hosts",
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.",
"idHelp": ""
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
},
"placeholders": {
"configKey": "Schlüssel",
@ -573,7 +573,7 @@
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
"delete_user_title": "Benutzer '%{name}' löschen",
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
"notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert",
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
"lastfmLinkFailure": "Last.fm konnte nicht verbunden werden",
@ -609,7 +609,7 @@
"coverRemoved": "Cover entfernt",
"coverUploadError": "Fehler beim Hochladen des Covers",
"coverRemoveError": "Fehler beim Entfernen des Covers",
"metadataRefreshStarted": ""
"metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund"
},
"menu": {
"library": "Bibliothek",
@ -731,4 +731,4 @@
"empty": "Keine Wiedergabe",
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
}
}
}

View file

@ -202,7 +202,7 @@
"command": "Komento"
},
"choices": {
"noDefaultBitRate": ""
"noDefaultBitRate": "Ei mikään"
}
},
"playlist": {
@ -609,7 +609,10 @@
"coverRemoved": "Kansikuva poistettu",
"coverUploadError": "Virhe ladattaessa kansikuvaa",
"coverRemoveError": "Virhe poistettaessa kansikuvaa",
"metadataRefreshStarted": "Metatietoja päivitetään taustalla"
"metadataRefreshStarted": "Metatietoja päivitetään taustalla",
"quickConnectApproved": "",
"quickConnectInvalidCode": "",
"quickConnectError": ""
},
"menu": {
"library": "Kirjasto",
@ -645,7 +648,15 @@
"selectLibraries": "Valitse kirjastot",
"none": "Ei mitään"
},
"onlyFavourites": "Näytä vain suosikit"
"onlyFavourites": "Näytä vain suosikit",
"quickConnect": {
"name": "",
"code": "",
"help": "",
"confirm": "",
"continue": "",
"approve": ""
}
},
"player": {
"playListsText": "Jono",

View file

@ -93,7 +93,8 @@
"addToPlaylist": "Toevoegen aan afspeellijst",
"download": "Downloaden",
"info": "Meer info",
"share": "Delen"
"share": "Delen",
"refresh": "Metadata verversen"
},
"lists": {
"all": "Alle",
@ -155,11 +156,13 @@
"newPassword": "Nieuw wachtwoord",
"token": "Token",
"lastAccessAt": "Meest recente toegang",
"libraries": "Bibliotheken"
"libraries": "Bibliotheken",
"scrobbleFilter": "Scrobble filter"
},
"helperTexts": {
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken",
"scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten."
},
"notifications": {
"created": "Aangemaakt door gebruiker",
@ -173,7 +176,8 @@
"adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
},
"validation": {
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers",
"invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay"
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "Doelformaat",
"defaultBitRate": "Standaard bitrate",
"command": "Commando"
},
"choices": {
"noDefaultBitRate": "Geen"
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "Nummers",
"comment": "Commentaar",
"sync": "Auto-importeren",
"path": "Importeer vanuit"
"path": "Importeer vanuit",
"starred": "Favoriet"
},
"actions": {
"selectPlaylist": "Selecteer een afspeellijst:",
@ -403,7 +411,8 @@
"requiredHosts": "Benodigde hosts",
"configValidationError": "Configuratiecheck mislukt",
"schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.",
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen."
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.",
"idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten."
},
"placeholders": {
"configKey": "Sleutel",
@ -599,7 +608,11 @@
"coverUploaded": "Albumhoes bijgewerkt",
"coverRemoved": "Albumhoes verwijderd",
"coverUploadError": "Fout bij het toevoegen albumhoes",
"coverRemoveError": "Fout bij verwijderen albumhoes"
"coverRemoveError": "Fout bij verwijderen albumhoes",
"metadataRefreshStarted": "Metadata verversen op de achtergrond",
"quickConnectApproved": "",
"quickConnectInvalidCode": "",
"quickConnectError": ""
},
"menu": {
"library": "Bibliotheek",
@ -634,6 +647,15 @@
"multipleLibraries": "%{selected} van %{total} bibliotheken",
"selectLibraries": "Selecteer bibliotheken",
"none": "Geen"
},
"onlyFavourites": "Toon alleen favorieten",
"quickConnect": {
"name": "",
"code": "",
"help": "",
"confirm": "",
"continue": "",
"approve": ""
}
},
"player": {

View file

@ -202,7 +202,7 @@
"command": "Comando"
},
"choices": {
"noDefaultBitRate": ""
"noDefaultBitRate": "Nenhum"
}
},
"playlist": {
@ -609,7 +609,10 @@
"coverRemoved": "Capa removida",
"coverUploadError": "Erro ao enviar capa",
"coverRemoveError": "Erro ao remover capa",
"metadataRefreshStarted": "Atualizando metadados em segundo plano"
"metadataRefreshStarted": "Atualizando metadados em segundo plano",
"quickConnectApproved": "%{app} em %{device} está conectado agora",
"quickConnectInvalidCode": "Código inválido ou expirado",
"quickConnectError": "Não foi possível aprovar o código"
},
"menu": {
"library": "Biblioteca",
@ -645,7 +648,15 @@
"selectLibraries": "Selecionar Bibliotecas",
"none": "Nenhuma"
},
"onlyFavourites": "Somente favoritas"
"onlyFavourites": "Somente favoritas",
"quickConnect": {
"name": "Conexão Rápida",
"code": "Código",
"help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta",
"confirm": "Conectar %{app} %{version} em %{device} à sua conta?",
"continue": "Continuar",
"approve": "Aprovar"
}
},
"player": {
"playListsText": "Fila de Execução",

View file

@ -11,22 +11,22 @@
"title": "Titel",
"artist": "Artist",
"album": "Album",
"path": "Sökväg",
"path": "Filsökväg",
"genre": "Genre",
"compilation": "Samling",
"compilation": "Samlingsalbum",
"year": "År",
"size": "Filstorlek",
"updatedAt": "Uppdaterad",
"bitRate": "Bitrate",
"discSubtitle": "Underrubrik",
"bitRate": "Bithastighet",
"discSubtitle": "Skivans undertitel",
"starred": "Favorit",
"comment": "Kommentar",
"rating": "Betyg",
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Senast spelad",
"channels": "Channels",
"createdAt": "Skapad",
"channels": "Kanaler",
"createdAt": "Tillagd",
"grouping": "Gruppering",
"mood": "Stämning",
"participants": "Ytterligare medverkande",
@ -35,19 +35,21 @@
"rawTags": "Omodifierade taggar",
"bitDepth": "Bitdjup",
"sampleRate": "Samplingsfrekvens",
"missing": "Saknade",
"missing": "Saknas",
"libraryName": "Bibliotek",
"composer": "Kompositör",
"disc": "Disc %{discNumber}"
"disc": "Skiva %{discNumber}",
"albumGain": "Volymjustering (album)",
"trackGain": "Volymjustering (låt)"
},
"actions": {
"addToQueue": "Lägg till i kön",
"playNow": "Spela nu",
"addToPlaylist": "Lägg till i spellista",
"shuffleAll": "Shuffle",
"shuffleAll": "Blanda alla",
"download": "Ladda ner",
"playNext": "Spela nästa",
"info": "Mer information",
"playNext": "Spela härnäst",
"info": "Visa information",
"showInPlaylist": "Visa i spellista",
"instantMix": "Direktmix"
}
@ -62,12 +64,12 @@
"playCount": "Spelningar",
"name": "Namn",
"genre": "Genre",
"compilation": "Samling",
"compilation": "Samlingsalbum",
"year": "År",
"updatedAt": "Uppdaterad",
"comment": "Kommentar",
"rating": "Betyg",
"createdAt": "Skapad",
"createdAt": "Tillagt",
"size": "Storlek",
"originalDate": "Originaldatum",
"releaseDate": "Utgivningsdatum",
@ -80,27 +82,28 @@
"media": "Media",
"mood": "Stämning",
"date": "Inspelningsdatum",
"missing": "Saknade",
"missing": "Saknas",
"libraryName": "Bibliotek"
},
"actions": {
"playAll": "Spela",
"playNext": "Spela härnäst",
"addToQueue": "Lägg till i kön",
"shuffle": "Shuffle",
"shuffle": "Blanda",
"addToPlaylist": "Lägg till i spellista",
"download": "Ladda ner",
"info": "Mer information",
"share": "Dela"
"info": "Visa information",
"share": "Dela",
"refresh": "Uppdatera metadata"
},
"lists": {
"all": "Alla",
"random": "Blanda",
"recentlyAdded": "Senast tillagda",
"recentlyPlayed": "Senast spelade",
"random": "Slumpmässiga",
"recentlyAdded": "Nyligen tillagda",
"recentlyPlayed": "Nyligen spelade",
"mostPlayed": "Mest spelade",
"starred": "Favoriter",
"topRated": "Bästa betyg"
"topRated": "Högst betygsatta"
}
},
"artist": {
@ -114,26 +117,26 @@
"genre": "Genre",
"size": "Storlek",
"role": "Roll",
"missing": "Saknade"
"missing": "Saknas"
},
"roles": {
"albumartist": "Albumartist |||| Albumartister",
"artist": "Artist |||| Artister",
"composer": "Kompositör |||| Kompositörer",
"composer": "Kompositör |||| Kompositörer",
"conductor": "Dirigent |||| Dirigenter",
"lyricist": "Textförfattare |||| Textförfattare",
"arranger": "Arrangör |||| Arrangörer",
"producer": "Producent |||| Producenter",
"director": "Inspelningsledare |||| Inspelningsledare",
"engineer": "Ljudtekniker |||| Ljudtekniker",
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
"performer": "Utövande artist |||| Utövande artister",
"maincredit": "Albumartister eller Artist |||| Albumartister eller Artister"
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
"performer": "Utövande artist |||| Utövande artister",
"maincredit": "Albumartist eller artist |||| Albumartister eller artister"
},
"actions": {
"shuffle": "Shuffle",
"shuffle": "Blanda",
"radio": "Radio",
"topSongs": "Topplåtar"
}
@ -142,7 +145,7 @@
"name": "Användare |||| Användare",
"fields": {
"userName": "Användarnamn",
"isAdmin": "Är admin",
"isAdmin": "Administratör",
"lastLoginAt": "Senaste inloggning",
"updatedAt": "Uppdaterad",
"name": "Namn",
@ -151,13 +154,15 @@
"changePassword": "Byt lösenord?",
"currentPassword": "Nuvarande lösenord",
"newPassword": "Nytt lösenord",
"token": "Token",
"token": "Åtkomsttoken",
"lastAccessAt": "Senaste åtkomst",
"libraries": "Bibliotek"
"libraries": "Bibliotek",
"scrobbleFilter": "Scrobblingsfilter"
},
"helperTexts": {
"name": "Ändringar av ditt namn syns först vid nästa inloggning",
"libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek"
"libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken",
"scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte."
},
"notifications": {
"created": "Användare skapad",
@ -165,13 +170,14 @@
"deleted": "Användare borttagen"
},
"message": {
"listenBrainzToken": "Ange din ListenBrainz användar-token.",
"listenBrainzToken": "Ange din användartoken för ListenBrainz.",
"clickHereForToken": "Klicka här för att hämta din token",
"selectAllLibraries": "Välj alla bibliotek",
"adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek"
"adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek"
},
"validation": {
"librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer"
"librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer",
"invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte."
}
},
"player": {
@ -179,12 +185,12 @@
"fields": {
"name": "Namn",
"transcodingId": "Omkodning",
"maxBitRate": "Max. bitrate",
"maxBitRate": "Högsta bithastighet",
"client": "Klient",
"userName": "Användarnamn",
"lastSeen": "Senast sedd",
"reportRealPath": "Visa hela sökvägen",
"scrobbleEnabled": "Scrobbla till extern tjänst"
"reportRealPath": "Rapportera den faktiska sökvägen",
"scrobbleEnabled": "Scrobbla till externa tjänster"
}
},
"transcoding": {
@ -192,8 +198,11 @@
"fields": {
"name": "Namn",
"targetFormat": "Målformat",
"defaultBitRate": "Standardbitrate",
"defaultBitRate": "Standardbithastighet",
"command": "Kommando"
},
"choices": {
"noDefaultBitRate": "Ingen"
}
},
"playlist": {
@ -207,8 +216,9 @@
"createdAt": "Skapad",
"songCount": "Låtar",
"comment": "Kommentar",
"sync": "Auto-import",
"path": "Importera från"
"sync": "Automatisk import",
"path": "Importera från",
"starred": "Favorit"
},
"actions": {
"selectPlaylist": "Välj en spellista:",
@ -216,24 +226,24 @@
"export": "Exportera",
"makePublic": "Gör offentlig",
"makePrivate": "Gör privat",
"saveQueue": "Spara kö till spellista",
"searchOrCreate": "Sök spellista eller skapa ny...",
"pressEnterToCreate": "Tryck Enter för att skapa ny spellista",
"removeFromSelection": "Ta bort från urval"
"saveQueue": "Spara kön i en spellista",
"searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...",
"pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista",
"removeFromSelection": "Ta bort från urvalet"
},
"message": {
"duplicate_song": "Lägg till dubletter",
"song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?",
"noPlaylistsFound": "Hittade inga spellistor",
"duplicate_song": "Lägg till dubbletter",
"song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?",
"noPlaylistsFound": "Inga spellistor hittades",
"noPlaylists": "Inga spellistor tillgängliga"
}
},
"radio": {
"name": "Radio |||| Radior",
"name": "Radiostation |||| Radiostationer",
"fields": {
"name": "Namn",
"streamUrl": "Stream-URL",
"homePageUrl": "Hemside-URL",
"streamUrl": "Strömmens URL",
"homePageUrl": "Webbplatsens URL",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad"
},
@ -242,7 +252,7 @@
}
},
"share": {
"name": "Dela |||| Delningar",
"name": "Delning |||| Delningar",
"fields": {
"username": "Delad av",
"url": "URL",
@ -252,11 +262,13 @@
"lastVisitedAt": "Senast besökt",
"visitCount": "Besök",
"format": "Format",
"maxBitRate": "Max. bitrate",
"maxBitRate": "Högsta bithastighet",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad",
"downloadable": "Tillåt nedladdning?"
}
"downloadable": "Tillåt nedladdningar?"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Saknad fil |||| Saknade filer",
@ -267,11 +279,11 @@
"libraryName": "Bibliotek"
},
"actions": {
"remove": "Radera",
"remove_all": "Radera alla"
"remove": "Ta bort",
"remove_all": "Ta bort alla"
},
"notifications": {
"removed": "Saknade fil(er) borttagna"
"removed": "Saknade filer har tagits bort"
},
"empty": "Inga saknade filer"
},
@ -280,8 +292,8 @@
"fields": {
"name": "Namn",
"path": "Sökväg",
"remotePath": "Ta bort sökväg",
"lastScanAt": "Senaste scan",
"remotePath": "Fjärrsökväg",
"lastScanAt": "Senaste skanning",
"songCount": "Låtar",
"albumCount": "Album",
"artistCount": "Artister",
@ -302,33 +314,33 @@
"statistics": "Statistik"
},
"actions": {
"scan": "Scanna bibliotek",
"scan": "Skanna biblioteket",
"manageUsers": "Hantera användaråtkomst",
"viewDetails": "Se detaljer",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan"
"viewDetails": "Visa detaljer",
"quickScan": "Snabbskanning",
"fullScan": "Fullständig skanning"
},
"notifications": {
"created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats",
"scanStarted": "Biblioteksscan startad",
"scanCompleted": "Biblioteksscan avslutad",
"quickScanStarted": "Snabbscan startad",
"fullScanStarted": "Komplett scan startad",
"scanError": "Fel vid start av scan. Se loggarna"
"deleted": "Biblioteket har tagits bort",
"scanStarted": "Skanning av biblioteket har startat",
"scanCompleted": "Skanning av biblioteket är klar",
"quickScanStarted": "Snabbskanning har startat",
"fullScanStarted": "Fullständig skanning har startat",
"scanError": "Kunde inte starta skanningen. Kontrollera loggarna"
},
"validation": {
"nameRequired": "Biblioteksnamn krävs",
"pathRequired": "Bibliotekssökväg krävs",
"pathNotDirectory": "Bibliotekssökvägen måste vara en katalog",
"pathNotFound": "Bibliotekssökväg hittades inte",
"pathNotAccessible": "Bibliotekssökväg inte tillgänglig",
"pathInvalid": "Ogiltig bibliotekssökväg"
"nameRequired": "Ange ett biblioteksnamn",
"pathRequired": "Ange en sökväg till biblioteket",
"pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog",
"pathNotFound": "Bibliotekets sökväg hittades inte",
"pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig",
"pathInvalid": "Ogiltig sökväg till biblioteket"
},
"messages": {
"deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.",
"scanInProgress": "Scanning pågår...",
"deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.",
"scanInProgress": "Skanning pågår...",
"noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren"
}
},
@ -339,44 +351,44 @@
"name": "Namn",
"description": "Beskrivning",
"version": "Version",
"author": "Författare",
"website": "Website",
"author": "Upphovsperson",
"website": "Webbplats",
"permissions": "Behörigheter",
"enabled": "Aktiverad",
"enabled": "Aktiverat",
"status": "Status",
"path": "Sökväg",
"lastError": "Fel",
"hasError": "Fel",
"updatedAt": "Uppdaterad",
"createdAt": "Installerad",
"updatedAt": "Uppdaterat",
"createdAt": "Installerat",
"configKey": "Nyckel",
"configValue": "Värde",
"allUsers": "Tillåt alla användare",
"selectedUsers": "Valda användare",
"allLibraries": "Tillåt alla bibliotek",
"selectedLibraries": "Valda bibliotek",
"allowWriteAccess": "Tillåt skrivrättigheter"
"allowWriteAccess": "Tillåt skrivåtkomst"
},
"sections": {
"status": "Status",
"info": "Tilläggsinformation",
"info": "Information om tillägget",
"configuration": "Konfiguration",
"manifest": "Manifest",
"usersPermission": "Användarbehörigheter",
"libraryPermission": "Biblioteksbehörigheter"
"usersPermission": "Åtkomst till användare",
"libraryPermission": "Åtkomst till bibliotek"
},
"status": {
"enabled": "Aktiverad",
"disabled": "Inaktiverad"
"enabled": "Aktiverat",
"disabled": "Inaktiverat"
},
"actions": {
"enable": "Aktivera",
"disable": "Inaktivera",
"disabledDueToError": "Åtgärda felet innan aktivering",
"disabledDueToError": "Åtgärda felet före aktivering",
"disabledUsersRequired": "Välj användare före aktivering",
"disabledLibrariesRequired": "Välj bibliotek före aktivering",
"addConfig": "Lägg till konfiguration",
"rescan": "Scanna om"
"rescan": "Skanna om"
},
"notifications": {
"enabled": "Tillägg aktiverat",
@ -391,17 +403,18 @@
"configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.",
"clickPermissions": "Klicka på en behörighet för mer information",
"noConfig": "Ingen konfiguration angiven",
"allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.",
"allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.",
"noUsers": "Inga användare valda",
"permissionReason": "Orsak",
"usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.",
"allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.",
"usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.",
"allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.",
"noLibraries": "Inga bibliotek valda",
"librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
"requiredHosts": "Krävda värdar",
"librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.",
"requiredHosts": "Värdar som krävs",
"configValidationError": "Validering av konfigurationen misslyckades:",
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
"allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter."
"schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
"allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.",
"idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents."
},
"placeholders": {
"configKey": "nyckel",
@ -412,40 +425,40 @@
"ra": {
"auth": {
"welcome1": "Tack för att du installerade Navidrome!",
"welcome2": "Skapa först ett admin-konto",
"welcome2": "Börja med att skapa ett administratörskonto",
"confirmPassword": "Bekräfta lösenord",
"buttonCreateAdmin": "Skapa admin-konto",
"buttonCreateAdmin": "Skapa administratörskonto",
"auth_check_error": "Logga in för att fortsätta",
"user_menu": "Profil",
"username": "Användarnamn",
"password": "Lösenord",
"sign_in": "Logga in",
"sign_in_error": "Felaktig inloggning, försök igen",
"sign_in_error": "Inloggningen misslyckades. Försök igen",
"logout": "Logga ut",
"insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill"
"insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen"
},
"validation": {
"invalidChars": "Använd enbart bokstäver och siffror",
"passwordDoesNotMatch": "Lösenordet matchar inte",
"required": "Krävs",
"minLength": "Måste ha minst %{min} tecken",
"maxLength": "Får maximalt ha %{max} tecken",
"passwordDoesNotMatch": "Lösenorden stämmer inte överens",
"required": "Obligatoriskt",
"minLength": "Måste innehålla minst %{min} tecken",
"maxLength": "Får innehålla högst %{max} tecken",
"minValue": "Måste vara minst %{min}",
"maxValue": "Får maximalt vara %{max}",
"number": "Måste vara ett nummer",
"maxValue": "Får vara högst %{max}",
"number": "Måste vara ett tal",
"email": "Måste vara en giltig e-postadress",
"oneOf": "Måste vara en av: %{options}",
"regex": "Måste matcha ett specifikt format (regexp): %{pattern}",
"unique": "Måste vara unik",
"oneOf": "Måste vara något av: %{options}",
"regex": "Måste matcha det reguljära uttrycket: %{pattern}",
"unique": "Måste vara unikt",
"url": "Måste vara en giltig URL"
},
"action": {
"add_filter": "Lägg till filter",
"add": "Lägg till",
"back": "Tillbaka",
"bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda",
"bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda",
"cancel": "Avbryt",
"clear_input_value": "Rensa",
"clear_input_value": "Rensa värdet",
"clone": "Klona",
"confirm": "Bekräfta",
"create": "Skapa",
@ -454,8 +467,8 @@
"export": "Exportera",
"list": "Lista",
"refresh": "Uppdatera",
"remove_filter": "Ta bort filter",
"remove": "Radera",
"remove_filter": "Ta bort det här filtret",
"remove": "Ta bort",
"save": "Spara",
"search": "Sök",
"show": "Visa",
@ -477,15 +490,15 @@
},
"page": {
"create": "Skapa %{name}",
"dashboard": "Dashboard",
"dashboard": "Översikt",
"edit": "%{name} #%{id}",
"error": "Ett fel uppstod",
"list": "%{name}",
"loading": "Laddar",
"not_found": "Hittade inget",
"loading": "Läser in",
"not_found": "Hittades inte",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} ännu.",
"invite": "Vill du lägga till en?"
"empty": "%{name}: inga poster ännu.",
"invite": "Vill du lägga till något?"
},
"input": {
"file": {
@ -497,13 +510,13 @@
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild."
},
"references": {
"all_missing": "Hittade ingen referensdata.",
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
"all_missing": "Kunde inte hitta referensdata.",
"many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.",
"single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig."
},
"password": {
"toggle_visible": "Dölj password",
"toggle_hidden": "Visa password"
"toggle_visible": "Dölj lösenord",
"toggle_hidden": "Visa lösenord"
}
},
"message": {
@ -511,16 +524,16 @@
"are_you_sure": "Är du säker?",
"bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?",
"bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}",
"delete_content": "Vill du verkligen ta bort detta innehåll?",
"delete_content": "Vill du verkligen ta bort det här objektet?",
"delete_title": "Ta bort %{name} #%{id}",
"details": "Detaljer",
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
"loading": "Sidan läses in, var god vänta",
"invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna",
"loading": "Sidan läses in. Vänta ett ögonblick",
"no": "Nej",
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
"yes": "Ja",
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
"unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?"
},
"navigation": {
"no_results": "Inga resultat hittades",
@ -529,23 +542,23 @@
"page_out_from_end": "Det finns inga fler sidor",
"page_out_from_begin": "Det finns ingen sida före sida 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Antal per sida:",
"page_rows_per_page": "Objekt per sida:",
"next": "Nästa",
"prev": "Föregående",
"skip_nav": "Hoppa till innehåll"
},
"notification": {
"updated": "Element uppdaterat |||| %{smart_count} element uppdaterade",
"created": "Element skapat",
"deleted": "Element borttaget |||| %{smart_count} element borttagna",
"bad_item": "Felaktigt element",
"item_doesnt_exist": "Element finns inte",
"updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade",
"created": "Objekt skapat",
"deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna",
"bad_item": "Felaktigt objekt",
"item_doesnt_exist": "Objektet finns inte",
"http_error": "Kommunikationsfel med servern",
"data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.",
"i18n_error": "Kunde inte läsa in översättningen av det valda språket",
"data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.",
"i18n_error": "Kunde inte läsa in översättningarna för det valda språket",
"canceled": "Åtgärden avbröts",
"logged_out": "Sessionen har avslutats, anslut på nytt.",
"new_version": "Det finns en ny version! Uppdatera detta fönster."
"logged_out": "Sessionen har avslutats. Logga in igen.",
"new_version": "En ny version finns tillgänglig! Ladda om det här fönstret."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolumner att visa",
@ -556,39 +569,39 @@
},
"message": {
"note": "OBSERVERA",
"transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).",
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
"songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan",
"noPlaylistsAvailable": "Ingen tillgänglig",
"delete_user_title": "Ta bort användare '%{name}'",
"delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?",
"notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar",
"notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https",
"lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt",
"lastfmLinkFailure": "Last.fm kunde inte länkas",
"lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat",
"lastfmUnlinkFailure": "Last.fm kunde inte avlänkas",
"transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.",
"transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.",
"songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan",
"noPlaylistsAvailable": "Inga tillgängliga",
"delete_user_title": "Ta bort användaren '%{name}'",
"delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?",
"notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar",
"notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS",
"lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats",
"lastfmLinkFailure": "Det gick inte att koppla Last.fm",
"lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats",
"lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm",
"openIn": {
"lastfm": "Öppna i Last.fm",
"musicbrainz": "Öppna i MusicBrainz"
},
"lastfmLink": "Läs mer...",
"listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat",
"listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas",
"listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}",
"listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats",
"listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz",
"downloadOriginalFormat": "Ladda ner i originalformat",
"shareOriginalFormat": "Dela i originalformat",
"shareDialogTitle": "Dela %{resource} '%{name}'",
"shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}",
"shareSuccess": "URL kopierades till urklipp: %{url}",
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
"shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}",
"shareSuccess": "URL:en har kopierats till urklipp: %{url}",
"shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp",
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
"remove_missing_title": "Ta bort saknade filer",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.",
"remove_all_missing_title": "Ta bort alla saknade filer",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.",
"noSimilarSongsFound": "Hittade inga liknande låtar",
"noTopSongsFound": "Hittade inga topplåtar",
"startingInstantMix": "Laddar direktmix...",
@ -597,7 +610,11 @@
"coverUploaded": "Omslagsbild uppdaterad",
"coverRemoved": "Omslagsbild borttagen",
"coverUploadError": "Fel vid uppladdning av omslagsbild",
"coverRemoveError": "Fel vid borttagning av omslagsbild"
"coverRemoveError": "Fel vid borttagning av omslagsbild",
"metadataRefreshStarted": "Metadata uppdateras i bakgrunden",
"quickConnectApproved": "%{app} på %{device} är nu inloggad",
"quickConnectInvalidCode": "Koden är ogiltig eller har gått ut",
"quickConnectError": "Kunde inte godkänna koden"
},
"menu": {
"library": "Bibliotek",
@ -610,17 +627,17 @@
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvy",
"desktop_notifications": "Skrivbordsmeddelanden",
"desktop_notifications": "Skrivbordsaviseringar",
"lastfmScrobbling": "Scrobbla till Last.fm",
"listenBrainzScrobbling": "Scrobbla till ListenBrainz",
"replaygain": "ReplayGain-läge",
"preAmp": "ReplayGain PreAmp (dB)",
"preAmp": "Förförstärkning för ReplayGain (dB)",
"gain": {
"none": "Inaktiverad",
"album": "Använd gain för album",
"track": "Använd gain für låtar"
"album": "Använd albumets volymjustering",
"track": "Använd låtens volymjustering"
},
"lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad"
"lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad"
}
},
"albumList": "Album",
@ -632,10 +649,19 @@
"multipleLibraries": "%{selected} av %{total} bibliotek",
"selectLibraries": "Välj bibliotek",
"none": "Inga"
}
},
"quickConnect": {
"name": "Snabbanslutning",
"code": "Kod",
"help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen",
"confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?",
"continue": "Fortsätt",
"approve": "Godkänn"
},
"onlyFavourites": "Visa endast favoriter"
},
"player": {
"playListsText": "Spela kön",
"playListsText": "Uppspelningskö",
"openText": "Öppna",
"closeText": "Stäng",
"notContentText": "Ingen musik",
@ -645,26 +671,26 @@
"previousTrackText": "Föregående låt",
"reloadText": "Ladda om",
"volumeText": "Volym",
"toggleLyricText": "Låttext av/på",
"toggleLyricText": "Visa eller dölj låttexten",
"toggleMiniModeText": "Minimera",
"destroyText": "Radera",
"destroyText": "Stäng spelaren och rensa kön",
"downloadText": "Ladda ner",
"removeAudioListsText": "Ta bort audiolistor",
"removeAudioListsText": "Rensa kön",
"clickToDeleteText": "Klicka för att ta bort %{name}",
"emptyLyricText": "Ingen låttext",
"playModeText": {
"order": "I ordningsföljd",
"orderLoop": "Upprepa",
"singleLoop": "Upprepa en",
"shufflePlay": "Shuffle"
"orderLoop": "Upprepa alla",
"singleLoop": "Upprepa en låt",
"shufflePlay": "Blanda"
}
},
"about": {
"links": {
"homepage": "Hemsida",
"homepage": "Webbplats",
"source": "Källkod",
"featureRequests": "Funktionalitetförfrågan",
"lastInsightsCollection": "Senaste Insights-kollektion",
"featureRequests": "Förslag på nya funktioner",
"lastInsightsCollection": "Senaste insamling av användningsdata",
"insights": {
"disabled": "Inaktiverad",
"waiting": "Väntar"
@ -672,16 +698,16 @@
},
"tabs": {
"about": "Om",
"config": "Inställningar"
"config": "Konfiguration"
},
"config": {
"configName": "Inställningsnamn",
"configName": "Inställning",
"environmentVariable": "Miljövariabel",
"currentValue": "Nuvarande värde",
"configurationFile": "Inställningsfil",
"exportToml": "Exportera inställningar (TOML)",
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
"exportFailed": "Kopiering av inställningarna misslyckades",
"currentValue": "Aktuellt värde",
"configurationFile": "Konfigurationsfil",
"exportToml": "Exportera konfiguration (TOML)",
"exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format",
"exportFailed": "Kunde inte kopiera konfigurationen",
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
"downloadToml": "Ladda ner konfiguration (TOML)"
@ -689,28 +715,28 @@
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Genomsökta mappar",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan",
"serverUptime": "Serverdrifttid",
"totalScanned": "Skannade mappar totalt",
"quickScan": "Snabb",
"fullScan": "Fullständig",
"serverUptime": "Serverns drifttid",
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
"elapsedTime": "Spelad tid",
"selectiveScan": "Urval"
"scanType": "Senaste skanning",
"status": "Skanningsfel",
"elapsedTime": "Förfluten tid",
"selectiveScan": "Selektiv"
},
"help": {
"title": "Navidrome kortkommandon",
"title": "Kortkommandon i Navidrome",
"hotkeys": {
"show_help": "Visa denna hjälp",
"toggle_menu": "Växla sidomeny",
"toggle_menu": "Visa eller dölj sidomenyn",
"toggle_play": "Spela / pausa",
"prev_song": "Föregående låt",
"next_song": "Nästa låt",
"vol_up": "Volym upp",
"vol_down": "Volym ner",
"toggle_love": "Lägg till låt i favoriter",
"current_song": "Hoppa till nuvarande låt"
"vol_up": "Höj volymen",
"vol_down": "Sänk volymen",
"toggle_love": "Lägg till låten i favoriter",
"current_song": "Gå till aktuell låt"
}
},
"nowPlaying": {
@ -718,4 +744,4 @@
"empty": "Inget spelas",
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
}
}
}

View file

@ -218,14 +218,14 @@ func UsernameFromExtAuthHeader(r *http.Request) string {
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}

View file

@ -16,12 +16,15 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"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/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
)
var _ = Describe("Auth", func() {
@ -234,6 +237,56 @@ var _ = Describe("Auth", func() {
})
})
Describe("UsernameFromExtAuthHeader", func() {
var hook *test.Hook
var r *http.Request
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
prevLevel := log.CurrentLevel()
l, h := test.NewNullLogger()
hook = h
prevLogger := log.SetDefaultLogger(l)
log.SetLevel(log.LevelWarn)
DeferCleanup(func() {
log.SetDefaultLogger(prevLogger)
log.SetLevel(prevLevel)
})
r = httptest.NewRequest("GET", "/", nil)
})
warnings := func() []*logrus.Entry {
var ws []*logrus.Entry
for _, e := range hook.AllEntries() {
if e.Level == logrus.WarnLevel {
ws = append(ws, e)
}
}
return ws
}
It("returns the username from a trusted source", func() {
r.Header.Set("Remote-User", "janedoe")
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42"))
Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe"))
Expect(warnings()).To(BeEmpty())
})
It("does not warn when an untrusted source sends no user header", func() {
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8"))
Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty())
Expect(warnings()).To(BeEmpty())
})
It("warns when an untrusted source sends the user header", func() {
r.Header.Set("Remote-User", "janedoe")
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8"))
Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty())
Expect(warnings()).To(HaveLen(1))
Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication"))
})
})
Describe("tokenFromHeader", func() {
It("returns the token when the Authorization header is set correctly", func() {
req := httptest.NewRequest("GET", "/", nil)

View file

@ -22,6 +22,10 @@ Enabled = true
ServerName = "My Music Server"
# Optional: usernames to show in the client login user-picker (default: none). See "Public user list".
ExposedPublicUsers = "alice, bob"
# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery".
AutoDiscovery = true
# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect".
QuickConnect = false
# Optional: max collection responses streaming at once (default: half the DB connection pool,
# min 2). Each streaming response holds a DB connection for its whole duration; excess requests
# queue rather than fail.
@ -34,6 +38,8 @@ or via environment variables:
ND_JELLYFIN_ENABLED=true
ND_JELLYFIN_SERVERNAME="My Music Server"
ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob"
ND_JELLYFIN_AUTODISCOVERY=true
ND_JELLYFIN_QUICKCONNECT=false
```
Once enabled, the API is mounted at:
@ -46,6 +52,26 @@ All the paths below are relative to that base URL (e.g. `System/Info/Public` mea
`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**,
since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths.
## Auto discovery
With `AutoDiscovery = true`, Navidrome answers the Jellyfin LAN discovery broadcast
(`who is JellyfinServer?` on UDP port 7359), so clients list the server without a typed URL.
It is off by default because a real Jellyfin server on the same host owns that port. If the port
is taken, Navidrome logs a warning and keeps running without discovery.
The advertised address is `BaseURL` when it includes a host. Otherwise it is the bind `Address`
when that is a specific IP, or else the local IP that faces the requesting client, plus `Port`. With a
unix socket `Address` there is no port to advertise, so discovery only starts when `BaseURL` has a host.
Discovery answers on all IPv4 interfaces, but the advertised address follows `BaseURL`, `Address` and
`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other
networks get an address they cannot reach. Set `BaseURL` to the address clients should use.
Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not
deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server.
With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host
networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet.
## Authentication
Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome
@ -60,6 +86,27 @@ surface.
Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch.
### Quick Connect
Quick Connect signs a new device in without typing a password. The client shows a 6-digit code,
a signed-in user approves it, and the client gets its `AccessToken`. It is on by default
(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other
Quick Connect call returns 401.
1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the
auth header) returns the `Code` and a `Secret`.
2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which
shows the app and device before approving) or from a signed-in Jellyfin client with
`POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user.
3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`.
4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as
`AuthenticateByName`.
Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike
Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval
(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some
clients poll it every second.
### Public user list (login picker)
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the
@ -118,18 +165,19 @@ returns direct children only (no tracks — no track is a library's direct child
| Area | Endpoints |
|---|---|
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` |
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) |
| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` |
| 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` |
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
| 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` |
| 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` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
| Lyrics | `GET Audio/{itemId}/Lyrics` |
| 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}]` |
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `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` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` |
| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) |
| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` |
@ -350,7 +398,7 @@ make test PKG=./server/jellyfin/...
just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel
check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per
track so sidecar/plugin lyrics also light up. Feishin additionally requires server version
≥ 10.9 — the reason `jellyfinVersion` is 10.9.11.
≥ 10.9 (`jellyfinVersion` advertises 12.1.0).
Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader`
is singleflighted), and the load runs detached from the request context with a one-minute bound,
so a cancelled request or hung plugin can't fail or pin the load for other waiters.

View file

@ -3,7 +3,6 @@ package jellyfin
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
@ -15,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -41,18 +41,18 @@ type Router struct {
broker events.Broker
lyricsCache cache.SimpleCache[string, model.LyricList]
similarFlight singleflight.Group
serverIDMu sync.Mutex
quickConnect quickconnect.QuickConnect
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, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router {
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
sonic: sonicSvc, lyrics: lyricsSvc, broker: broker,
sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000,
DefaultTTL: 5 * time.Minute,
@ -83,6 +83,11 @@ func (api *Router) routes() http.Handler {
login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
}
login.Post("/users/authenticatebyname", api.authenticateByName)
quickConnectLogin := login.With(requireQuickConnect)
quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate)
quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect)
// Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown.
inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect)
inner.Get("/users/public", api.getPublicUsers)
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
@ -93,6 +98,8 @@ func (api *Router) routes() http.Handler {
conf.Server.DevArtworkThrottleBacklogTimeout))
r.Get("/items/{itemId}/images/{type}", api.getItemImage)
r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage)
r.Head("/items/{itemId}/images/{type}", api.getItemImage)
r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage)
})
inner.Group(func(r chi.Router) {
@ -107,6 +114,12 @@ func (api *Router) routes() http.Handler {
r.Get("/users/{userId}/views", api.getUserViews)
r.Get("/users/me", api.getCurrentUser)
r.Get("/users/{userId}", api.getCurrentUser)
// Throttled like login so a signed-in user cannot enumerate other people's pending codes.
approve := r.With(requireQuickConnect)
if conf.Server.AuthRequestLimit > 0 {
approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
}
approve.Post("/quickconnect/authorize", api.quickConnectAuthorize)
// 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
@ -145,6 +158,12 @@ func (api *Router) routes() http.Handler {
r.Get("/items/{itemId}/similar", api.getSimilarItems)
r.Get("/albums/{itemId}/similar", api.getSimilarAlbums)
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
r.Get("/songs/{itemId}/instantmix", api.getInstantMix)
r.Get("/albums/{itemId}/instantmix", api.getInstantMix)
r.Get("/artists/{itemId}/instantmix", api.getInstantMix)
r.Get("/playlists/{itemId}/instantmix", api.getInstantMix)
r.Get("/artists/instantmix", api.getInstantMixByQuery)
r.Get("/musicgenres/instantmix", api.getInstantMixByQuery)
r.Get("/genres", api.getGenres)
r.Get("/musicgenres", api.getGenres)
r.Get("/studios", api.getStudios)
@ -155,6 +174,7 @@ func (api *Router) routes() http.Handler {
r.Post("/playlists/{playlistId}", api.updatePlaylist)
r.Post("/playlists/{playlistId}/items", api.addToPlaylist)
r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist)
r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem)
r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers)
r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser)
@ -166,6 +186,10 @@ func (api *Router) routes() http.Handler {
r.Get("/audio/{itemId}/stream", api.streamAudio)
r.Get("/audio/{itemId}/stream.{container}", api.streamAudio)
r.Get("/audio/{itemId}/universal", api.streamUniversal)
// Fintunes probes these with HEAD for the content type before playing or downloading.
r.Head("/audio/{itemId}/stream", api.streamAudio)
r.Head("/audio/{itemId}/stream.{container}", api.streamAudio)
r.Head("/audio/{itemId}/universal", api.streamUniversal)
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
@ -174,12 +198,15 @@ func (api *Router) routes() http.Handler {
// /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.Head("/items/{itemId}/file", api.streamFile)
r.Head("/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)
r.Post("/sessions/playing/ping", api.acknowledge)
r.Post("/sessions/capabilities", api.acknowledge)
r.Post("/sessions/capabilities/full", api.acknowledge)
// Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect.
r.Get("/socket", api.handleSocket)

View file

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -19,7 +20,7 @@ import (
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, nil, nil)
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -27,7 +28,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for unknown routes", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r)
@ -37,7 +38,7 @@ var _ = Describe("Router", func() {
})
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, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -54,7 +55,7 @@ var _ = Describe("Router", func() {
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil)
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -71,7 +72,7 @@ var _ = Describe("Router", 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, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
login := func() int {
w := httptest.NewRecorder()
@ -86,11 +87,38 @@ var _ = Describe("Router", func() {
Expect(login()).To(Equal(http.StatusTooManyRequests))
})
It("rate-limits Quick Connect approval by IP when a login limit is configured", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 2
conf.Server.AuthWindowLength = time.Minute
conf.Server.Jellyfin.QuickConnect = true
ds := &tests.MockDataStore{}
auth.Init(ds)
usr := model.User{ID: testID("alice"), UserName: "alice"}
Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed())
token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New())
authorize := func() int {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil)
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Emby-Token", token)
api.ServeHTTP(w, r)
return w.Code
}
// An unknown code is 404; the limiter cuts in on the 3rd attempt with 429.
Expect(authorize()).To(Equal(http.StatusNotFound))
Expect(authorize()).To(Equal(http.StatusNotFound))
Expect(authorize()).To(Equal(http.StatusTooManyRequests))
})
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Every request arrives on the same proxy connection, so only the resolved client IP
// can separate the buckets.
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)

View file

@ -30,10 +30,15 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
api.signIn(w, r, usr)
}
func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) {
ctx := r.Context()
// 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)
log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err)
}
token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
@ -45,7 +50,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
a := parseMediaBrowserAuth(r)
serverID := api.serverID(ctx)
api.ok(w, r, dto.AuthenticationResult{
User: userToDto(usr, api.serverName(), serverID),
User: userToDto(usr, serverName(), serverID),
SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID),
AccessToken: token,
ServerId: serverID,

View file

@ -0,0 +1,110 @@
package jellyfin
import (
"context"
"encoding/json"
"net"
"strconv"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/publicurl"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gg"
)
// Jellyfin clients broadcast this query text to this UDP port.
const (
discoveryPort = 7359
discoveryQuery = "who is jellyfinserver?"
)
type discoveryInfo struct {
Address string `json:"Address"`
Id string `json:"Id"`
Name string `json:"Name"`
EndpointAddress *string `json:"EndpointAddress"`
}
// Discovery answers LAN auto-discovery broadcasts with the same identity the Router reports.
type Discovery struct {
ds model.DataStore
serverIDVal string
}
func NewDiscovery(ds model.DataStore) *Discovery {
return &Discovery{ds: ds}
}
func (d *Discovery) serverID(ctx context.Context) string {
return resolveServerID(ctx, d.ds, &d.serverIDVal)
}
// Serve runs until ctx is done. A failed bind is only logged: discovery is best-effort.
func (d *Discovery) Serve(ctx context.Context) {
if !hasAdvertisableAddress() {
log.Warn(ctx, "Jellyfin API: auto-discovery is off, a unix socket server needs a BaseURL with a host to advertise")
return
}
// udp4 only: a dual-stack bind can share the port with another server and never get a packet.
conn, err := net.ListenPacket("udp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(discoveryPort)))
if err != nil {
log.Warn(ctx, "Jellyfin API: auto-discovery is off, the UDP port is unavailable. Is another Jellyfin server running?", "port", discoveryPort, err)
return
}
log.Info(ctx, "Jellyfin API: listening for auto-discovery broadcasts", "port", discoveryPort)
d.ServeOn(ctx, conn)
}
// ServeOn answers discovery queries on conn until ctx is done, then closes conn.
func (d *Discovery) ServeOn(ctx context.Context, conn net.PacketConn) {
defer conn.Close()
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
buf := make([]byte, 1024)
for {
n, remote, err := conn.ReadFrom(buf)
if err != nil {
if ctx.Err() == nil {
log.Error(ctx, "Jellyfin API: auto-discovery listener stopped", err)
}
return
}
if !strings.Contains(strings.ToLower(string(buf[:n])), discoveryQuery) {
continue
}
info := discoveryInfo{Address: discoveryAddress(ctx, remote), Id: d.serverID(ctx), Name: serverName()}
res, _ := json.Marshal(info)
log.Debug(ctx, "Jellyfin API: answering auto-discovery request", "from", remote.String(), "address", info.Address)
if _, err := conn.WriteTo(res, remote); err != nil {
log.Debug(ctx, "Jellyfin API: could not answer auto-discovery request", "to", remote.String(), err)
}
}
}
// Behind a unix socket nothing listens on Port, so only a BaseURL host gives clients an address.
func hasAdvertisableAddress() bool {
return conf.Server.BaseHost != "" || !strings.HasPrefix(conf.Server.Address, "unix:")
}
func discoveryAddress(ctx context.Context, remote net.Addr) string {
scheme := gg.If(conf.Server.TLSEnabled(), "https", "http")
host := net.JoinHostPort(localIPFor(remote), strconv.Itoa(conf.Server.Port))
return publicurl.AbsoluteURL(request.WithServerAddress(ctx, scheme, host), consts.URLPathJellyfinAPI, nil)
}
// On a multi-homed host, only the interface that routes to the requester is reachable by it.
func localIPFor(remote net.Addr) string {
if ip := parseIP(conf.Server.Address); ip.IsValid() && !ip.IsUnspecified() {
return ip.String()
}
c, err := net.Dial("udp", remote.String())
if err != nil {
return conf.Server.Address
}
defer c.Close()
return c.LocalAddr().(*net.UDPAddr).IP.String()
}

View file

@ -0,0 +1,175 @@
package jellyfin
import (
"context"
"encoding/json"
"errors"
"net"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type failingConn struct {
net.PacketConn
closed bool
}
func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) {
return 0, nil, errors.New("read failed")
}
func (c *failingConn) Close() error {
c.closed = true
return nil
}
var _ = Describe("Discovery", func() {
var d *Discovery
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ServerName = "Test Server"
conf.Server.Address = "0.0.0.0"
conf.Server.Port = 4533
conf.Server.BaseHost = ""
conf.Server.BaseScheme = ""
conf.Server.BasePath = ""
conf.Server.TLSCert = ""
conf.Server.TLSKey = ""
d = &Discovery{}
})
DescribeTable("discoveryAddress",
func(setup func(), expected string) {
setup()
remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000}
Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected))
},
Entry("uses the BaseURL host and scheme when set", func() {
conf.Server.BaseScheme = "https"
conf.Server.BaseHost = "music.example.com"
conf.Server.BasePath = "/nd"
}, "https://music.example.com/nd/jellyfin"),
Entry("uses a specific bind Address with the Port", func() {
conf.Server.Address = "192.168.1.10"
}, "http://192.168.1.10:4533/jellyfin"),
Entry("falls back to the interface facing the requester when Address is unspecified", func() {},
"http://127.0.0.1:4533/jellyfin"),
Entry("falls back to the interface facing the requester when Address is empty", func() {
conf.Server.Address = ""
}, "http://127.0.0.1:4533/jellyfin"),
Entry("advertises https when TLS is configured", func() {
conf.Server.TLSCert = "/path/cert.pem"
conf.Server.TLSKey = "/path/key.pem"
}, "https://127.0.0.1:4533/jellyfin"),
Entry("advertises http when only the TLS cert is configured", func() {
conf.Server.TLSCert = "/path/cert.pem"
}, "http://127.0.0.1:4533/jellyfin"),
Entry("keeps a path-only BaseURL as the path prefix", func() {
conf.Server.BasePath = "/music"
}, "http://127.0.0.1:4533/music/jellyfin"),
Entry("does not double the slash when BasePath has a trailing slash", func() {
conf.Server.BasePath = "/music/"
}, "http://127.0.0.1:4533/music/jellyfin"),
)
DescribeTable("hasAdvertisableAddress",
func(address, baseHost string, expected bool) {
conf.Server.Address = address
conf.Server.BaseHost = baseHost
Expect(hasAdvertisableAddress()).To(Equal(expected))
},
Entry("TCP listener", "0.0.0.0", "", true),
Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false),
Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true),
)
It("closes the connection when the read loop fails", func() {
fake := &failingConn{}
d.ServeOn(context.Background(), fake)
Expect(fake.closed).To(BeTrue())
})
Describe("ServeOn", func() {
var (
server net.PacketConn
client net.PacketConn
cancel context.CancelFunc
done chan struct{}
)
BeforeEach(func() {
var err error
server, err = net.ListenPacket("udp4", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
client, err = net.ListenPacket("udp4", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(client.Close)
var ctx context.Context
ctx, cancel = context.WithCancel(context.Background())
done = make(chan struct{})
go func() {
defer close(done)
d.ServeOn(ctx, server)
}()
DeferCleanup(func() {
cancel()
Eventually(done).Should(BeClosed())
})
})
send := func(msg string) {
_, err := client.WriteTo([]byte(msg), server.LocalAddr())
Expect(err).ToNot(HaveOccurred())
}
receive := func() ([]byte, error) {
Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed())
buf := make([]byte, 1024)
n, _, err := client.ReadFrom(buf)
return buf[:n], err
}
It("answers the discovery query with the server identity", func() {
send("who is JellyfinServer?")
res, err := receive()
Expect(err).ToNot(HaveOccurred())
var info discoveryInfo
Expect(json.Unmarshal(res, &info)).To(Succeed())
Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin"))
Expect(info.Id).To(Equal(d.serverID(context.Background())))
Expect(info.Name).To(Equal("Test Server"))
Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`))
})
It("matches the query case-insensitively", func() {
send("WHO IS JELLYFINSERVER?")
_, err := receive()
Expect(err).ToNot(HaveOccurred())
})
// The loop is serial, so a reply to the first packet would arrive before the second's.
It("ignores unrelated packets", func() {
send("who is PlexServer?")
send("who is JellyfinServer?")
_, err := receive()
Expect(err).ToNot(HaveOccurred())
Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed())
_, _, err = client.ReadFrom(make([]byte, 1024))
Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue())
})
It("stops and closes the socket when the context is cancelled", func() {
cancel()
Eventually(done).Should(BeClosed())
_, _, err := server.ReadFrom(make([]byte, 1))
Expect(err).To(HaveOccurred())
})
})
})

View file

@ -233,6 +233,17 @@ type PlayerStateInfo struct {
PlaybackOrder string `json:"PlaybackOrder"`
}
type QuickConnectResult struct {
Authenticated bool `json:"Authenticated"`
Secret string `json:"Secret"`
Code string `json:"Code"`
DeviceId string `json:"DeviceId"`
DeviceName string `json:"DeviceName"`
AppName string `json:"AppName"`
AppVersion string `json:"AppVersion"`
DateAdded string `json:"DateAdded"`
}
type AuthenticationResult struct {
User *UserDto `json:"User"`
SessionInfo *SessionInfo `json:"SessionInfo"`

View file

@ -51,16 +51,16 @@ func premiereDate(date string, year int) *string {
if err != nil {
return nil
}
s := jellyfinDate(&parsed)
s := JellyfinDate(&parsed)
return &s
}
// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339.
const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00"
// jellyfinDate formats t as the date string clients expect, or "" for the zero time so the
// JellyfinDate formats t as the date 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 {
func JellyfinDate(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
@ -135,7 +135,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r
}
if s := jellyfinDate(a.PlayDate); s != "" {
if s := JellyfinDate(a.PlayDate); s != "" {
d.LastPlayedDate = &s
}
return d
@ -159,7 +159,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
AlbumId: albumID,
AlbumArtist: mf.AlbumArtist,
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),
DateCreated: JellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
CanDownload: true,
BackdropImageTags: []string{},
@ -265,7 +265,7 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
ChildCount: new(al.SongCount),
SongCount: new(al.SongCount),
RunTimeTicks: TicksFromSeconds(al.Duration),
DateCreated: jellyfinDate(&al.CreatedAt),
DateCreated: JellyfinDate(&al.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@ -318,7 +318,7 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
IsFolder: true,
AlbumCount: new(ar.AlbumCount),
SongCount: new(ar.SongCount),
DateCreated: jellyfinDate(ar.CreatedAt),
DateCreated: JellyfinDate(ar.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@ -346,7 +346,7 @@ func LibraryToBaseItem(lib model.Library) BaseItemDto {
IsFolder: true,
Path: lib.Path,
LocationType: "FileSystem",
DateCreated: jellyfinDate(&lib.CreatedAt),
DateCreated: JellyfinDate(&lib.CreatedAt),
ChildCount: new(lib.TotalAlbums),
UserData: &UserItemDataDto{Key: id, ItemId: id},
BackdropImageTags: []string{},
@ -386,7 +386,7 @@ func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto {
MediaType: "Audio",
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
DateCreated: jellyfinDate(&p.CreatedAt),
DateCreated: JellyfinDate(&p.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
@ -460,7 +460,7 @@ func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, server
DeviceName: deviceName,
ApplicationVersion: version,
ServerId: serverID,
LastActivityDate: jellyfinDate(&now),
LastActivityDate: JellyfinDate(&now),
IsActive: true,
PlayableMediaTypes: []string{"Audio"},
SupportedCommands: []string{},

View file

@ -17,10 +17,10 @@ var _ = Describe("mappers", func() {
ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
PlayCount: 2,
Starred: true,
}
mf.PlayCount = 2
mf.Starred = true
item := SongToBaseItem(mf, nil)
Expect(item.Type).To(Equal("Audio"))
Expect(item.MediaType).To(Equal("Audio"))

View file

@ -0,0 +1,50 @@
package e2e
import (
"context"
"encoding/json"
"net"
"time"
"github.com/navidrome/navidrome/server/jellyfin"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Auto discovery", func() {
BeforeEach(func() { setupTestDB() })
It("advertises the same Id and Name as /System/Info/Public", func() {
server, err := net.ListenPacket("udp4", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
client, err := net.ListenPacket("udp4", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(client.Close)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
jellyfin.NewDiscovery(ds).ServeOn(ctx, server)
}()
DeferCleanup(func() {
cancel()
Eventually(done).Should(BeClosed())
})
_, err = client.WriteTo([]byte("who is JellyfinServer?"), server.LocalAddr())
Expect(err).ToNot(HaveOccurred())
Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed())
buf := make([]byte, 1024)
n, _, err := client.ReadFrom(buf)
Expect(err).ToNot(HaveOccurred())
var reply map[string]any
Expect(json.Unmarshal(buf[:n], &reply)).To(Succeed())
var pub map[string]any
parseInto(rawReq("GET", "/System/Info/Public", ""), &pub)
Expect(reply["Id"]).To(Equal(pub["Id"]))
Expect(reply["Name"]).To(Equal(pub["ServerName"]))
Expect(reply["Address"]).To(HaveSuffix("/jellyfin"))
})
})

View file

@ -44,6 +44,7 @@ import (
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/storage/storagetest"
@ -338,6 +339,7 @@ func setupTestDB() {
sonicSvc,
lyrics.NewLyrics(ds, nil),
events.NoopBroker(),
quickconnect.New(),
)
}

View file

@ -41,6 +41,10 @@ var _ = Describe("Item images", func() {
Expect(u.IsAdmin).To(BeTrue())
})
It("answers HEAD without authentication", func() {
Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK))
})
It("serves images without authentication (public route)", func() {
id := albumID("IV")
w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "")

View file

@ -19,6 +19,7 @@ var _ = Describe("Playlists", func() {
playlistItems := func(plID string) dto.QueryResult {
return queryResult(get("/Playlists/" + enc(plID) + "/Items"))
}
order := func(plID string) []string { return names(playlistItems(plID).Items) }
Describe("create", func() {
It("creates an empty playlist", func() {
@ -102,6 +103,36 @@ var _ = Describe("Playlists", func() {
Expect(playlistItems(plID).TotalRecordCount).To(BeZero())
})
Describe("at a position", func() {
var plID string
addAt := func(position string, ids ...string) {
url := "/Playlists/" + enc(plID) + "/Items?position=" + position
for _, id := range ids {
url += "&ids=" + enc(id)
}
Expect(post(url, "").Code).To(Equal(http.StatusNoContent))
}
BeforeEach(func() {
plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))})
})
DescribeTable("inserts at Jellyfin's zero-based position",
func(position string, want []string) {
addAt(position, songID("So What"), songID("Help!"))
Expect(order(plID)).To(Equal(want))
},
Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}),
Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}),
Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}),
)
It("keeps an expanded album's track order", func() {
albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))}))
addAt("1", albumID("Abbey Road"))
Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something")))
})
})
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
@ -118,6 +149,58 @@ var _ = Describe("Playlists", func() {
})
})
Describe("move", func() {
move := func(u model.User, plID, entryID string, newIndex string) int {
return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code
}
var plID string
var entries []dto.BaseItemDto
BeforeEach(func() {
plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))})
entries = playlistItems(plID).Items
})
DescribeTable("moves an entry to Jellyfin's zero-based index",
func(entry int, newIndex string, want []string) {
Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent))
Expect(order(plID)).To(Equal(want))
var positions []string
for _, it := range playlistItems(plID).Items {
positions = append(positions, it.PlaylistItemId)
}
Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")}))
},
Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}),
Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}),
Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}),
Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}),
)
It("ignores an entry that is not in the playlist", func() {
Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent))
Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"}))
})
It("404s on a malformed entry id", func() {
Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound))
})
It("hides another user's private playlist", func() {
Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound))
Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"}))
})
It("forbids a non-owner on a public playlist", func() {
Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden))
Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"}))
})
It("rejects a negative index", func() {
Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest))
})
})
Describe("users", func() {
It("reports the current user as an editor", func() {
plID := createPlaylist("Perms", nil)

View file

@ -0,0 +1,91 @@
package e2e
import (
"net/http"
"net/http/httptest"
"strings"
"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("QuickConnect", func() {
BeforeEach(func() {
setupTestDB()
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.QuickConnect = true
})
clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`)
r.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, r)
return w
}
initiate := func() dto.QuickConnectResult {
var pending dto.QuickConnectResult
parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending)
Expect(pending.Authenticated).To(BeFalse())
return pending
}
redeem := func(deviceID, secret string) *httptest.ResponseRecorder {
return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`)
}
It("signs a new client in with a code approved by a signed-in user", func() {
Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true"))
pending := initiate()
Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK))
var status dto.QuickConnectResult
parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status)
Expect(status.Authenticated).To(BeTrue())
// Android TV redeems with a different DeviceId than it initiated with.
w := redeem("other-device", pending.Secret)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.AuthenticationResult
parseInto(w, &res)
Expect(res.User.Name).To(Equal(regularUser.UserName))
Expect(res.SessionInfo).ToNot(BeNil())
Expect(res.SessionInfo.DeviceId).To(Equal("other-device"))
r := httptest.NewRequest("GET", "/Users/Me", nil)
r.Header.Set("X-Emby-Token", res.AccessToken)
me := httptest.NewRecorder()
router.ServeHTTP(me, r)
Expect(me.Code).To(Equal(http.StatusOK))
Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound))
})
It("lets an admin approve a code for another user", func() {
pending := initiate()
Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code).
To(Equal(http.StatusOK))
var res dto.AuthenticationResult
parseInto(redeem("new-device", pending.Secret), &res)
Expect(res.User.Name).To(Equal(regularUser.UserName))
})
It("requires authentication to approve a code", func() {
pending := initiate()
Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized))
})
It("answers 401 on every Quick Connect call when disabled", func() {
conf.Server.Jellyfin.QuickConnect = false
Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false"))
Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized))
Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized))
Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized))
Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized))
})
})

View file

@ -37,6 +37,10 @@ var _ = Describe("Sessions", func() {
Expect(mf.PlayCount).To(BeNumerically(">=", 1))
})
It("acknowledges a playback session ping", func() {
Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent))
})
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%

View file

@ -150,4 +150,33 @@ var _ = Describe("Similar", func() {
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("type-specific InstantMix routes", func() {
BeforeEach(func() {
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}}
})
It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() {
q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"}))
})
DescribeTable("returns the provider's mix",
func(path func() string) {
q := queryResult(get(path()))
Expect(names(q.Items)).To(Equal([]string{"Mix Song"}))
},
Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }),
Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }),
Entry("Playlists/{id}", func() string {
return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix"
}),
Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }),
Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }),
)
It("404s a malformed id query param", func() {
Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound))
})
})
})

View file

@ -1,6 +1,7 @@
package e2e
import (
"fmt"
"net/http"
"strings"
@ -45,6 +46,19 @@ var _ = Describe("Streaming", func() {
It("returns 404 for an unknown track", func() {
Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound))
})
DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)",
func(path string) {
w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty())
},
Entry("stream", "/Audio/%s/stream"),
Entry("stream.{container}", "/Audio/%s/stream.mp3"),
Entry("universal", "/Audio/%s/universal"),
Entry("File", "/Items/%s/File"),
Entry("Download", "/Items/%s/Download"),
)
})
Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() {

View file

@ -81,12 +81,4 @@ var _ = Describe("System", func() {
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"))
})
})
})

View file

@ -23,14 +23,17 @@ import (
_ "golang.org/x/image/webp"
)
// imageSize picks the tighter of Jellyfin's two bounds, because Navidrome resizes on a single
// dimension: reading only MaxWidth serves the full-size original to a client that sent MaxHeight.
func imageSize(maxWidth, maxHeight int) int {
w, h := max(maxWidth, 0), max(maxHeight, 0)
if w == 0 || h == 0 {
return max(w, h)
// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are
// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound.
func imageSize(p *req.Values) int {
fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0))
size := 0
for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} {
if v > 0 && (size == 0 || v < size) {
size = v
}
}
return min(w, h)
return size
}
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
@ -41,8 +44,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
p := req.Params(r)
size := imageSize(p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0))
size := imageSize(req.Params(r))
artID := api.resolveArtworkID(ctx, itemId)
img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false)

View file

@ -65,7 +65,7 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request)
var _ = Describe("Images", func() {
// Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must
// still get a resized image rather than the full-size original.
DescribeTable("derives the requested size from MaxWidth or MaxHeight",
DescribeTable("derives the requested size from the Jellyfin size params",
func(query string, wantSize int) {
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
@ -84,6 +84,15 @@ var _ = Describe("Images", func() {
Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200),
Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200),
Entry("neither", "", 0),
Entry("Width only", "width=300", 300),
Entry("Height only", "height=300", 300),
Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300),
Entry("FillWidth only", "fillwidth=300", 300),
Entry("FillHeight only", "fillheight=300", 300),
Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300),
Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300),
Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200),
Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250),
)
It("streams album artwork", func() {

View file

@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
@ -261,8 +263,8 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin
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; a locked playlist maps to 403, any other error to 404.
// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position
// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404.
func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
@ -275,7 +277,13 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
return
}
ids := api.expandContainerIDs(ctx, decoded)
if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil {
var err error
if position, perr := req.Params(r).Int64("position"); perr == nil {
_, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position))
} else {
_, err = api.playlists.AddTracks(ctx, id, ids)
}
if err != nil {
if errors.Is(err, model.ErrPlaylistNotEditable) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
@ -286,6 +294,12 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so
// it can't wrap on 32-bit builds.
func insertPosition(position int64) int {
return int(min(max(position, 0), math.MaxInt32-1) + 1)
}
// 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; a locked playlist maps to 403, any other error to 404.
@ -316,6 +330,38 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end
// indexes and rejects unknown entries, which Jellyfin treats as a no-op.
func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId"))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex"))
if err != nil || newIndex < 0 {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op.
if _, err := api.playlists.Get(ctx, id); err != nil {
api.playlistError(w, r, err)
return
}
pos, _ := strconv.Atoi(entry)
err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1)
if err != nil && !errors.Is(err, model.ErrNotFound) {
api.playlistError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries
// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing.
func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) {

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
@ -133,6 +134,17 @@ func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error
return f.removeImageErr
}
var _ = DescribeTable("insertPosition",
func(position int64, want int) {
Expect(insertPosition(position)).To(Equal(want))
},
Entry("zero-based index becomes a 1-based position", int64(2), 3),
Entry("negative prepends", int64(-5), 1),
Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32),
Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32),
Entry("smallest int64 prepends", int64(math.MinInt64), 1),
)
var _ = Describe("Playlists", func() {
var api *Router
var fp *fakePlaylists

View file

@ -0,0 +1,145 @@
package jellyfin
import (
"encoding/json"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
func requireQuickConnect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !conf.Server.Jellyfin.QuickConnect {
http.Error(w, "Quick connect is disabled", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, conf.Server.Jellyfin.QuickConnect)
}
// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded.
const maxQuickConnectField = 512
func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) {
a := parseMediaBrowserAuth(r)
if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" ||
max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField {
http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest)
return
}
req, err := api.quickConnect.Initiate(quickconnect.Device{
ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version,
})
if errors.Is(err, quickconnect.ErrTooManyRequests) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
if err != nil {
api.internalError(w, r, err)
return
}
api.ok(w, r, quickConnectResult(req))
}
func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) {
req, err := api.quickConnect.Status(r.URL.Query().Get("secret"))
if err != nil {
http.Error(w, "Unknown secret", http.StatusNotFound)
return
}
api.ok(w, r, quickConnectResult(req))
}
func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
caller, _ := request.UserFrom(ctx)
userID := caller.ID
if encoded := r.URL.Query().Get("userid"); encoded != "" {
var ok bool
if userID, ok = dto.DecodeID(encoded); !ok {
http.Error(w, "Invalid userId", http.StatusBadRequest)
return
}
}
target := caller
if userID != caller.ID {
if !caller.IsAdmin {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
usr, err := api.ds.User(ctx).Get(userID)
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "Unknown user", http.StatusNotFound)
return
}
if err != nil {
api.internalError(w, r, err)
return
}
target = *usr
}
req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID)
switch {
case errors.Is(err, model.ErrNotFound):
http.Error(w, "Unknown code", http.StatusNotFound)
case errors.Is(err, quickconnect.ErrAlreadyAuthorized):
http.Error(w, "Code already used", http.StatusConflict)
case err != nil:
api.internalError(w, r, err)
default:
log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName,
"approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name)
api.ok(w, r, true)
}
}
func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var body struct {
Secret string `json:"Secret"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
userID, err := api.quickConnect.Redeem(body.Secret)
if err != nil {
http.Error(w, "Unknown secret", http.StatusNotFound)
return
}
usr, err := api.ds.User(ctx).Get(userID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err != nil {
api.internalError(w, r, err)
return
}
api.signIn(w, r, usr)
}
func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult {
return dto.QuickConnectResult{
Authenticated: req.Authorized(),
Secret: req.Secret,
Code: req.Code,
DeviceId: req.Device.ID,
DeviceName: req.Device.Name,
AppName: req.Device.App,
AppVersion: req.Device.AppVersion,
DateAdded: dto.JellyfinDate(&req.DateAdded),
}
}

View file

@ -0,0 +1,301 @@
package jellyfin
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/quickconnect"
"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 fullQuickConnect struct{ quickconnect.QuickConnect }
func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) {
return quickconnect.Request{}, quickconnect.ErrTooManyRequests
}
var _ = Describe("QuickConnect", func() {
const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"`
var (
api *Router
ds *tests.MockDataStore
qc quickconnect.QuickConnect
alice = model.User{ID: testID("alice"), UserName: "alice"}
bob = model.User{ID: testID("bob"), UserName: "bob"}
admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true}
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.QuickConnect = true
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
for _, u := range []model.User{alice, bob, admin} {
Expect(ur.Put(&u)).To(Succeed())
}
qc = quickconnect.New()
api = &Router{ds: ds, quickConnect: qc}
})
as := func(r *http.Request, u model.User) *http.Request {
return r.WithContext(request.WithUser(r.Context(), u))
}
initiate := func() quickconnect.Request {
req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"})
Expect(err).ToNot(HaveOccurred())
return req
}
Describe("GET /QuickConnect/Enabled", func() {
DescribeTable("reports the config value",
func(enabled bool, expected string) {
conf.Server.Jellyfin.QuickConnect = enabled
w := httptest.NewRecorder()
api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(MatchJSON(expected))
},
Entry("enabled", true, "true"),
Entry("disabled", false, "false"),
)
})
Describe("requireQuickConnect", func() {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) })
It("rejects requests with 401 when Quick Connect is disabled", func() {
conf.Server.Jellyfin.QuickConnect = false
w := httptest.NewRecorder()
requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("passes requests through when Quick Connect is enabled", func() {
w := httptest.NewRecorder()
requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil))
Expect(w.Code).To(Equal(http.StatusTeapot))
})
})
Describe("POST /QuickConnect/Initiate", func() {
initiateWith := func(authHeader string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)
r.Header.Set("Authorization", authHeader)
api.quickConnectInitiate(w, r)
return w
}
It("returns all QuickConnectResult fields, with the device from the auth header", func() {
w := initiateWith(finamp)
Expect(w.Code).To(Equal(http.StatusOK))
var raw map[string]any
Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed())
// sdk-kotlin declares every field non-null.
Expect(raw).To(HaveKeyWithValue("Authenticated", false))
Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`)))
Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`)))
Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1"))
Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7"))
Expect(raw).To(HaveKeyWithValue("AppName", "Finamp"))
Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0"))
Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty())))
_, err := qc.Status(raw["Secret"].(string))
Expect(err).ToNot(HaveOccurred())
})
It("returns 400 when the auth header does not identify the client", func() {
w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when a client field is oversized", func() {
long := strings.Repeat("x", maxQuickConnectField+1)
api.quickConnect = fullQuickConnect{qc}
w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 429 when too many requests are pending", func() {
api.quickConnect = fullQuickConnect{qc}
Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests))
})
})
Describe("GET /QuickConnect/Connect", func() {
connect := func(secret string) (int, dto.QuickConnectResult) {
w := httptest.NewRecorder()
invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil))
var res dto.QuickConnectResult
if w.Code == http.StatusOK {
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
}
return w.Code, res
}
It("reports whether the request has been authorized", func() {
req := initiate()
code, res := connect(req.Secret)
Expect(code).To(Equal(http.StatusOK))
Expect(res.Authenticated).To(BeFalse())
Expect(res.Code).To(Equal(req.Code))
_, err := qc.Authorize(req.Code, alice.ID)
Expect(err).ToNot(HaveOccurred())
code, res = connect(req.Secret)
Expect(code).To(Equal(http.StatusOK))
Expect(res.Authenticated).To(BeTrue())
})
It("returns 404 for an unknown secret", func() {
code, _ := connect("unknown")
Expect(code).To(Equal(http.StatusNotFound))
})
})
Describe("POST /QuickConnect/Authorize", func() {
authorize := func(query string, u model.User) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u))
return w
}
approver := func(req quickconnect.Request) string {
got, err := qc.Status(req.Secret)
Expect(err).ToNot(HaveOccurred())
return got.UserID
}
It("approves the code for the caller when no userId is given", func() {
req := initiate()
w := authorize("code="+req.Code, alice)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.String()).To(MatchJSON("true"))
Expect(approver(req)).To(Equal(alice.ID))
})
It("accepts the caller's own userId", func() {
req := initiate()
w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(approver(req)).To(Equal(alice.ID))
})
It("forbids a non-admin from approving for another user", func() {
req := initiate()
w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice)
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(approver(req)).To(BeEmpty())
})
It("lets an admin approve for another user", func() {
req := initiate()
w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(approver(req)).To(Equal(bob.ID))
})
It("returns 404 when an admin approves for an unknown user", func() {
req := initiate()
w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(approver(req)).To(BeEmpty())
})
It("returns 400 for a malformed userId", func() {
req := initiate()
w := authorize("code="+req.Code+"&userId=not-a-guid", admin)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 404 for an unknown code", func() {
w := authorize("code=000000", alice)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 409 for a code that is already approved", func() {
req := initiate()
Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK))
Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict))
Expect(approver(req)).To(Equal(alice.ID))
})
})
Describe("POST /Users/AuthenticateWithQuickConnect", func() {
redeem := func(body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body))
r.Header.Set("Authorization", finamp)
api.authenticateWithQuickConnect(w, r)
return w
}
redeemSecret := func(secret string) *httptest.ResponseRecorder {
return redeem(`{"Secret":"` + secret + `"}`)
}
It("signs in the approving user once", func() {
req := initiate()
_, _ = qc.Authorize(req.Code, alice.ID)
w := redeemSecret(req.Secret)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.AuthenticationResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.User.Name).To(Equal("alice"))
Expect(res.ServerId).ToNot(BeEmpty())
Expect(res.SessionInfo).ToNot(BeNil())
Expect(res.SessionInfo.DeviceId).To(Equal("dev-1"))
claims, err := auth.Validate(res.AccessToken)
Expect(err).ToNot(HaveOccurred())
Expect(claims.Subject).To(Equal("alice"))
Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound))
})
It("accepts a camelCase body", func() {
req := initiate()
_, _ = qc.Authorize(req.Code, alice.ID)
Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK))
})
It("returns 404 while the request is not approved", func() {
req := initiate()
Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound))
})
DescribeTable("returns 400 for a bad body",
func(body string) {
Expect(redeem(body).Code).To(Equal(http.StatusBadRequest))
},
Entry("not JSON", `nope`),
Entry("no secret", `{}`),
)
It("returns 401 when the approving user no longer exists", func() {
req := initiate()
_, _ = qc.Authorize(req.Code, testID("ghost"))
Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized))
})
It("returns 500 when the user lookup fails", func() {
req := initiate()
_, _ = qc.Authorize(req.Code, alice.ID)
ds.User(context.Background()).(*tests.MockedUserRepo).Error = errors.New("db down")
Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError))
})
})
})

View file

@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() {
var api *Router
BeforeEach(func() {
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("serves a fully lowercase path directly", func() {

View file

@ -113,8 +113,8 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// postCapabilities acknowledges Jellyfin session-capability negotiation.
// Navidrome doesn't track per-session client capabilities, so this is a no-op.
func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) {
// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per
// session) and playback pings (transcodes live only as long as their stream request).
func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}

View file

@ -152,12 +152,12 @@ var _ = Describe("Sessions", func() {
})
})
Describe("postCapabilities", func() {
Describe("acknowledge", func() {
It("returns 204 No Content and does not touch the scrobbler", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`)))
api.postCapabilities(w, r)
api.acknowledge(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(pt.reported).To(BeEmpty())

View file

@ -111,11 +111,26 @@ func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) {
// a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty
// results, never a 404 the client would surface as an error.
func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
api.instantMix(w, r, id)
}
// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms,
// which take the seed as ?id= instead of a path segment.
func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) {
id, ok := dto.DecodeID(req.Params(r).StringOr("id", ""))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.instantMix(w, r, id)
}
func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) {
ctx := r.Context()
limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit)
// Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a

View file

@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() {
Expect(err).ToNot(HaveOccurred())
token = t
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("upgrades when authenticated via the api_key query parameter", func() {

View file

@ -4,6 +4,7 @@ import (
"cmp"
"fmt"
"math"
"mime"
"net/http"
"net/url"
"slices"
@ -139,6 +140,14 @@ func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.
func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) {
ctx := r.Context()
// A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes)
// this is not direct play; direct play falls through to Serve, which answers HEAD itself.
if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" {
w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format))
w.Header().Set("Accept-Ranges", "none")
w.WriteHeader(http.StatusOK)
return
}
s, err := api.streamer.NewStream(ctx, mf, streamReq)
if err != nil {
api.internalError(w, r, err)

View file

@ -244,6 +244,36 @@ var _ = Describe("Stream", func() {
})
})
Describe("HEAD requests", func() {
head := func(query string) *httptest.ResponseRecorder {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1},
})
streamer.content = "audio-bytes"
w := httptest.NewRecorder()
r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
return w
}
It("answers a transcode with the target type and no length, without starting it", func() {
w := head("audioCodec=mp3")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg"))
Expect(w.Header().Get("Content-Length")).To(BeEmpty())
Expect(w.Body.String()).To(BeEmpty())
Expect(streamer.invoked).To(BeFalse())
})
It("answers direct play through the streamer, without a body", func() {
w := head("static=true")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamer.invoked).To(BeTrue())
Expect(w.Body.String()).To(BeEmpty())
})
})
Describe("streamUniversal", func() {
universal := func(query string) {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{

View file

@ -10,6 +10,7 @@ import (
"net/netip"
"path"
"strings"
"sync"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
@ -20,40 +21,41 @@ import (
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate
// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required
// for Feishin to use the server lyrics endpoint.
const jellyfinVersion = "10.9.11"
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on
// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts.
const jellyfinVersion = "12.1.0"
func (api *Router) serverName() string {
func serverName() string {
if conf.Server.Jellyfin.ServerName != "" {
return conf.Server.Jellyfin.ServerName
}
return fmt.Sprintf("Navidrome %s", consts.Version)
}
// serverID returns a stable Id that survives restarts, get-or-created in the Property table.
// Jellyfin clients cache ServerId across sessions, so a per-process value would break
// re-authentication. api.ds is nil only in unit tests; New() always sets it.
//
// The mutex serializes first-boot resolution so concurrent requests can't persist different
// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a
// temporary id and retries on the next request rather than pinning a value.
func (api *Router) serverID(ctx context.Context) string {
api.serverIDMu.Lock()
defer api.serverIDMu.Unlock()
if api.serverIDVal != "" {
return api.serverIDVal
return resolveServerID(ctx, api.ds, &api.serverIDVal)
}
// Package-level: the Router and Discovery are separate objects and must not persist different ids.
var serverIDMu sync.Mutex
// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is
// cached: a transient failure yields a temporary id and retries on the next call.
func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string {
serverIDMu.Lock()
defer serverIDMu.Unlock()
if *cached != "" {
return *cached
}
if api.ds == nil {
api.serverIDVal = newServerID()
return api.serverIDVal
if ds == nil {
*cached = newServerID()
return *cached
}
id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey)
id, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey)
switch {
case errors.Is(err, model.ErrNotFound):
id = newServerID()
if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil {
if err := ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil {
log.Error(ctx, "Jellyfin API: could not persist server id", err)
return id
}
@ -62,8 +64,8 @@ func (api *Router) serverID(ctx context.Context) string {
return newServerID()
}
// Ids persisted before this change are dashed; normalize on read rather than rewriting the DB.
api.serverIDVal = strings.ReplaceAll(id, "-", "")
return api.serverIDVal
*cached = strings.ReplaceAll(id, "-", "")
return *cached
}
// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")).
@ -75,7 +77,7 @@ func newServerID() string {
func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo {
return dto.PublicSystemInfo{
LocalAddress: localAddress(r),
ServerName: api.serverName(),
ServerName: serverName(),
Version: jellyfinVersion,
ProductName: "Jellyfin Server",
Id: api.serverID(r.Context()),
@ -107,7 +109,7 @@ func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) {
func (api *Router) ping(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(api.serverName()))
_, _ = w.Write([]byte(serverName()))
}
// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a
@ -151,7 +153,3 @@ func parseIP(addr string) netip.Addr {
}
return ip.Unmap()
}
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, false)
}

View file

@ -7,6 +7,7 @@ import (
"net"
"net/http"
"net/http/httptest"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -144,17 +145,6 @@ var _ = Describe("System", func() {
Expect(info.IsInNetwork).To(BeTrue())
})
It("reports quick connect as disabled", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)
api.quickConnectEnabled(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var enabled bool
Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed())
Expect(enabled).To(BeFalse())
})
Context("serverID with a real DataStore", func() {
var ctx context.Context
var ds *tests.MockDataStore
@ -173,6 +163,17 @@ var _ = Describe("System", func() {
Expect(second.serverID(ctx)).To(Equal(id))
})
It("resolves one id when a Router and a Discovery race on first boot", func() {
r, d := &Router{ds: ds}, NewDiscovery(ds)
ids := make([]string, 2)
var wg sync.WaitGroup
wg.Go(func() { ids[0] = r.serverID(ctx) })
wg.Go(func() { ids[1] = d.serverID(ctx) })
wg.Wait()
Expect(ids[0]).ToNot(BeEmpty())
Expect(ids[1]).To(Equal(ids[0]))
})
It("memoizes the id across repeated calls on the same Router", func() {
r := &Router{ds: ds}
id := r.serverID(ctx)

View file

@ -34,7 +34,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u, _ := request.UserFrom(ctx)
api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx)))
api.ok(w, r, userToDto(&u, serverName(), api.serverID(ctx)))
}
// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login

View file

@ -232,15 +232,20 @@ func trustedProxyPrefixes(list string) []string {
return prefixes
}
// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware,
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers
// cannot be rotated for a fresh bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
return ClientIP(r), nil
})
}
// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The
// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket.
func ClientIP(r *http.Request) string {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
// can only be set once.
func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler {

View file

@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View file

@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
conf.Server.EnableSharing = false
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View file

@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() {
}
auth.Init(ds)
provider = &fakeProvider{}
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
router = server.JWTVerifier(nativeRouter)
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}

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