mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 21:07:10 +02:00
Merge branch 'master' into listenbrainz-artist-url/6165
This commit is contained in:
commit
250a9f4b4d
78 changed files with 2664 additions and 154 deletions
13
cmd/root.go
13
cmd/root.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,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/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -79,7 +80,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
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +137,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
|
||||
}
|
||||
|
||||
|
|
@ -168,6 +171,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)
|
||||
|
|
@ -249,7 +259,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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
203
core/quickconnect/quickconnect.go
Normal file
203
core/quickconnect/quickconnect.go
Normal 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), "")
|
||||
}
|
||||
17
core/quickconnect/quickconnect_suite_test.go
Normal file
17
core/quickconnect/quickconnect_suite_test.go
Normal 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")
|
||||
}
|
||||
190
core/quickconnect/quickconnect_test.go
Normal file
190
core/quickconnect/quickconnect_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -184,6 +184,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)
|
||||
|
|
|
|||
|
|
@ -195,6 +195,29 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
|
|||
return len(mediaFileIds), 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)
|
||||
}
|
||||
return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
|
||||
sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number")
|
||||
var ids []string
|
||||
|
|
@ -247,8 +270,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,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
|
||||
|
|
|
|||
|
|
@ -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,25 @@ 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: publish the port (`-p 7359:7359/udp`). In bridge mode the server only sees its container
|
||||
IP, so also set `ND_BASEURL` to the LAN address (for example `http://192.168.1.10:4533`), or use
|
||||
host networking. 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 +85,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 +164,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 +397,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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
110
server/jellyfin/discovery.go
Normal file
110
server/jellyfin/discovery.go
Normal 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()
|
||||
}
|
||||
175
server/jellyfin/discovery_test.go
Normal file
175
server/jellyfin/discovery_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
50
server/jellyfin/e2e/discovery_test.go
Normal file
50
server/jellyfin/e2e/discovery_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
91
server/jellyfin/e2e/quickconnect_test.go
Normal file
91
server/jellyfin/e2e/quickconnect_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
145
server/jellyfin/quickconnect.go
Normal file
145
server/jellyfin/quickconnect.go
Normal 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),
|
||||
}
|
||||
}
|
||||
301
server/jellyfin/quickconnect_test.go
Normal file
301
server/jellyfin/quickconnect_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ var _ = Describe("Missing Files Endpoint", func() {
|
|||
token, err = auth.CreateToken(&user)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil))
|
||||
router = server.JWTVerifier(New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil))
|
||||
})
|
||||
|
||||
DescribeTable("GET /missing/{id}",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
|
|
@ -48,10 +49,11 @@ type Router struct {
|
|||
pluginManager PluginManager
|
||||
imgUpload artwork.Uploader
|
||||
provider external.Provider
|
||||
quickConnect quickconnect.QuickConnect
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider}
|
||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
|
|
@ -88,6 +90,7 @@ func (api *Router) routes() http.Handler {
|
|||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
api.addQuickConnectRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
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)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
|||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
|
|||
ds = &tests.MockDataStore{}
|
||||
mockManager = &tests.MockPluginManager{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
|
||||
// Create test users
|
||||
|
|
|
|||
76
server/nativeapi/quickconnect.go
Normal file
76
server/nativeapi/quickconnect.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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"
|
||||
)
|
||||
|
||||
type quickConnectDevice struct {
|
||||
AppName string `json:"appName"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
}
|
||||
|
||||
func (api *Router) addQuickConnectRoute(r chi.Router) {
|
||||
if !quickconnect.Enabled() {
|
||||
return
|
||||
}
|
||||
r.Route("/quickconnect", func(r chi.Router) {
|
||||
// Throttled like login so a signed-in user cannot enumerate other people's pending codes.
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
|
||||
}
|
||||
r.Get("/", lookupQuickConnect(api.quickConnect))
|
||||
r.Post("/authorize", authorizeQuickConnect(api.quickConnect))
|
||||
})
|
||||
}
|
||||
|
||||
func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := qc.Lookup(r.URL.Query().Get("code"))
|
||||
writeQuickConnectResult(w, r, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
req, err := qc.Authorize(body.Code, user.ID)
|
||||
if err == nil {
|
||||
log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name)
|
||||
}
|
||||
writeQuickConnectResult(w, r, req, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) {
|
||||
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:
|
||||
log.Error(r.Context(), "Quick Connect failed", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
default:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name})
|
||||
}
|
||||
}
|
||||
148
server/nativeapi/quickconnect_test.go
Normal file
148
server/nativeapi/quickconnect_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Quick Connect endpoints", func() {
|
||||
var (
|
||||
qc quickconnect.QuickConnect
|
||||
pending quickconnect.Request
|
||||
user = model.User{ID: "u1", UserName: "alice"}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
qc = quickconnect.New()
|
||||
var err error
|
||||
pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
asUser := func(r *http.Request) *http.Request {
|
||||
return r.WithContext(request.WithUser(r.Context(), user))
|
||||
}
|
||||
decode := func(w *httptest.ResponseRecorder) quickConnectDevice {
|
||||
var res quickConnectDevice
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
||||
return res
|
||||
}
|
||||
finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"}
|
||||
|
||||
Describe("GET /quickconnect", func() {
|
||||
lookup := func(code string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil)))
|
||||
return w
|
||||
}
|
||||
|
||||
It("describes the device waiting for the code", func() {
|
||||
w := lookup(pending.Code)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(decode(w)).To(Equal(finamp))
|
||||
})
|
||||
|
||||
It("does not approve the code", func() {
|
||||
lookup(pending.Code)
|
||||
got, _ := qc.Status(pending.Secret)
|
||||
Expect(got.Authorized()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 for an unknown code", func() {
|
||||
Expect(lookup("000000").Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 409 for a code that is already approved", func() {
|
||||
_, _ = qc.Authorize(pending.Code, "someone")
|
||||
Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("POST /quickconnect/authorize", func() {
|
||||
authorize := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body))))
|
||||
return w
|
||||
}
|
||||
|
||||
It("approves the code for the signed-in user", func() {
|
||||
w := authorize(`{"code":"` + pending.Code + `"}`)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(decode(w)).To(Equal(finamp))
|
||||
got, _ := qc.Status(pending.Secret)
|
||||
Expect(got.UserID).To(Equal(user.ID))
|
||||
})
|
||||
|
||||
It("returns 404 for an unknown code", func() {
|
||||
Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 409 for a code that is already approved", func() {
|
||||
_, _ = qc.Authorize(pending.Code, "someone")
|
||||
Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict))
|
||||
})
|
||||
|
||||
It("returns 400 for a bad body", func() {
|
||||
Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("route registration", func() {
|
||||
serve := func() int {
|
||||
r := chi.NewRouter()
|
||||
(&Router{quickConnect: qc}).addQuickConnectRoute(r)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil)))
|
||||
return w.Code
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Jellyfin.Enabled = true
|
||||
conf.Server.Jellyfin.QuickConnect = true
|
||||
})
|
||||
|
||||
It("mounts the routes when Jellyfin Quick Connect is on", func() {
|
||||
Expect(serve()).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("rate-limits code attempts when a login limit is configured", func() {
|
||||
conf.Server.AuthRequestLimit = 2
|
||||
conf.Server.AuthWindowLength = time.Minute
|
||||
r := chi.NewRouter()
|
||||
(&Router{quickConnect: qc}).addQuickConnectRoute(r)
|
||||
attempt := func() int {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil)
|
||||
req.RemoteAddr = "10.0.0.1:1234"
|
||||
r.ServeHTTP(w, asUser(req))
|
||||
return w.Code
|
||||
}
|
||||
Expect(attempt()).To(Equal(http.StatusNotFound))
|
||||
Expect(attempt()).To(Equal(http.StatusNotFound))
|
||||
Expect(attempt()).To(Equal(http.StatusTooManyRequests))
|
||||
})
|
||||
|
||||
It("skips the routes when the Jellyfin API is off", func() {
|
||||
conf.Server.Jellyfin.Enabled = false
|
||||
Expect(serve()).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("skips the routes when Quick Connect is off", func() {
|
||||
conf.Server.Jellyfin.QuickConnect = false
|
||||
Expect(serve()).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
|
|||
auth.Init(ds)
|
||||
|
||||
userService := core.NewUser(ds, noopPluginUnloader{})
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/mime"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
|
|
@ -75,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
|||
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
|
||||
"enableExternalServices": conf.Server.EnableExternalServices,
|
||||
"enableReplayGain": conf.Server.EnableReplayGain,
|
||||
"enableQuickConnect": quickconnect.Enabled(),
|
||||
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
|
||||
"separator": string(os.PathSeparator),
|
||||
"enableInspect": conf.Server.Inspect.Enabled,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ var _ = Describe("serveIndex", func() {
|
|||
Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true),
|
||||
Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true),
|
||||
Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true),
|
||||
Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true),
|
||||
Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false),
|
||||
Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true),
|
||||
Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true),
|
||||
Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type MockPlaylistTrackRepo struct {
|
|||
DeletedIds []string
|
||||
Reordered bool
|
||||
AddCount int
|
||||
InsertPos int
|
||||
Err error
|
||||
AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options
|
||||
}
|
||||
|
|
@ -89,6 +90,11 @@ func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) {
|
|||
return m.AddCount, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistTrackRepo) Insert(ids []string, pos int) (int, error) {
|
||||
m.InsertPos = pos
|
||||
return m.Add(ids)
|
||||
}
|
||||
|
||||
func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) {
|
||||
if m.Err != nil {
|
||||
return 0, m.Err
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
SizeField,
|
||||
useAlbumsPerPage,
|
||||
} from '../common'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
|
@ -151,6 +152,7 @@ export const Details = (props) => {
|
|||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const translate = useTranslate()
|
||||
const record = useRecordContext(props)
|
||||
const locale = useDateLocale()
|
||||
|
||||
// Create an array of detail elements
|
||||
let details = []
|
||||
|
|
@ -161,12 +163,13 @@ export const Details = (props) => {
|
|||
|
||||
// Calculate date related fields
|
||||
const yearRange = formatRange(record, 'year')
|
||||
const date = record.date ? formatFullDate(record.date) : yearRange
|
||||
const date = record.date ? formatFullDate(record.date, locale) : yearRange
|
||||
|
||||
const originalDate = record.originalDate
|
||||
? formatFullDate(record.originalDate)
|
||||
? formatFullDate(record.originalDate, locale)
|
||||
: formatRange(record, 'originalYear')
|
||||
const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate)
|
||||
const releaseDate =
|
||||
record?.releaseDate && formatFullDate(record.releaseDate, locale)
|
||||
|
||||
const dateToUse = originalDate || date
|
||||
const isOriginalDate = originalDate && dateToUse !== date
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import React from 'react'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
import { DateField as RADateField } from 'react-admin'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
|
||||
export const DateField = (props) => {
|
||||
const { record, source } = props
|
||||
const locale = useDateLocale()
|
||||
const value = record?.[source]
|
||||
if (!isDateSet(value)) return null
|
||||
return <RADateField {...props} />
|
||||
return <RADateField locales={locale} {...props} />
|
||||
}
|
||||
|
||||
DateField.defaultProps = {
|
||||
|
|
|
|||
32
ui/src/common/DateField.test.jsx
Normal file
32
ui/src/common/DateField.test.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { DateField } from './DateField'
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('<DateField>', () => {
|
||||
const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' }
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
|
||||
const { useLocale } = await import('react-admin')
|
||||
vi.mocked(useLocale).mockReturnValue('de')
|
||||
})
|
||||
|
||||
it('formats the date using the selected language', () => {
|
||||
render(<DateField record={record} source="updatedAt" />)
|
||||
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when the date is not set', () => {
|
||||
const { container } = render(
|
||||
<DateField record={{ id: '1' }} source="updatedAt" />,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton'
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatDateTime } from '../utils/formatters'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import config from '../config'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
|
@ -40,6 +42,7 @@ export const LoveButton = ({
|
|||
const record = useRecordContext({ record: recordProp }) || {}
|
||||
const classes = useStyles({ color, visible, loved: record.starred })
|
||||
const [toggleLove, loading] = useToggleLove(resource, record)
|
||||
const locale = useDateLocale()
|
||||
|
||||
const handleToggleLove = useCallback(
|
||||
(e) => {
|
||||
|
|
@ -61,7 +64,7 @@ export const LoveButton = ({
|
|||
className={clsx(classes.love, className)}
|
||||
title={
|
||||
isDateSet(record.starredAt)
|
||||
? new Date(record.starredAt).toLocaleString()
|
||||
? formatDateTime(record.starredAt, locale)
|
||||
: undefined
|
||||
}
|
||||
{...rest}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations'
|
|||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import clsx from 'clsx'
|
||||
import { useRating } from './useRating'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatDateTime } from '../utils/formatters'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
|
|
@ -32,6 +34,7 @@ export const RatingField = ({
|
|||
const record = useRecordContext(rest) || {}
|
||||
const [rate, rating] = useRating(resource, record)
|
||||
const classes = useStyles({ color, visible })
|
||||
const locale = useDateLocale()
|
||||
|
||||
const stopPropagation = (e) => {
|
||||
e.stopPropagation()
|
||||
|
|
@ -50,7 +53,7 @@ export const RatingField = ({
|
|||
onClick={(e) => stopPropagation(e)}
|
||||
title={
|
||||
isDateSet(record.ratedAt)
|
||||
? new Date(record.ratedAt).toLocaleString()
|
||||
? formatDateTime(record.ratedAt, locale)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const defaultConfig = {
|
|||
devUIShowConfig: true,
|
||||
devNewEventStream: false,
|
||||
enableReplayGain: true,
|
||||
enableQuickConnect: false,
|
||||
defaultDownsamplingFormat: 'opus',
|
||||
publicBaseUrl: '/share',
|
||||
separator: '/',
|
||||
|
|
|
|||
|
|
@ -218,6 +218,15 @@ const wrapperDataProvider = {
|
|||
({ json }) => ({ data: json }),
|
||||
)
|
||||
},
|
||||
lookupQuickConnect: (code) =>
|
||||
httpClient(
|
||||
`${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`,
|
||||
).then(({ json }) => ({ data: json })),
|
||||
authorizeQuickConnect: (code) =>
|
||||
httpClient(`${REST_URL}/quickconnect/authorize`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}).then(({ json }) => ({ data: json })),
|
||||
inspect: (songId) => {
|
||||
return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
|
||||
data: json,
|
||||
|
|
|
|||
128
ui/src/dialogs/QuickConnectDialog.jsx
Normal file
128
ui/src/dialogs/QuickConnectDialog.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useDataProvider, useNotify, useTranslate } from 'react-admin'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from '@material-ui/core'
|
||||
|
||||
export const QuickConnectDialog = ({ open, onClose }) => {
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const dataProvider = useDataProvider()
|
||||
const [code, setCode] = useState('')
|
||||
const [pending, setPending] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
setCode('')
|
||||
setPending(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleError = (error) => {
|
||||
setPending(null)
|
||||
const invalid = error?.status === 404 || error?.status === 409
|
||||
notify(
|
||||
invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError',
|
||||
'warning',
|
||||
)
|
||||
}
|
||||
|
||||
const run = (request, onSuccess) => {
|
||||
setLoading(true)
|
||||
request
|
||||
.then(({ data }) => onSuccess(data))
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
const lookup = (event) => {
|
||||
event.preventDefault()
|
||||
run(dataProvider.lookupQuickConnect(code), setPending)
|
||||
}
|
||||
|
||||
const approve = () =>
|
||||
run(dataProvider.authorizeQuickConnect(code), (data) => {
|
||||
notify('message.quickConnectApproved', 'success', {
|
||||
app: data.appName,
|
||||
device: data.deviceName,
|
||||
})
|
||||
handleClose()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="quick-connect-dialog"
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle id="quick-connect-dialog">
|
||||
{translate('menu.quickConnect.name')}
|
||||
</DialogTitle>
|
||||
{pending ? (
|
||||
<>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{translate('menu.quickConnect.confirm', {
|
||||
app: pending.appName,
|
||||
version: pending.appVersion,
|
||||
device: pending.deviceName,
|
||||
})}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPending(null)} color="primary">
|
||||
{translate('ra.action.back')}
|
||||
</Button>
|
||||
<Button onClick={approve} color="primary" disabled={loading}>
|
||||
{translate('menu.quickConnect.approve')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={lookup}>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{translate('menu.quickConnect.help')}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
id="quickConnectCode"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
autoFocus
|
||||
label={translate('menu.quickConnect.code')}
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
inputProps={{ inputMode: 'numeric', autoComplete: 'off' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary">
|
||||
{translate('ra.action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={!code.trim() || loading}
|
||||
>
|
||||
{translate('menu.quickConnect.continue')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
QuickConnectDialog.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
}
|
||||
103
ui/src/dialogs/QuickConnectDialog.test.jsx
Normal file
103
ui/src/dialogs/QuickConnectDialog.test.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from 'react'
|
||||
import { TestContext } from 'ra-test'
|
||||
import { DataProviderContext } from 'react-admin'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { describe, afterEach, it, expect, vi } from 'vitest'
|
||||
import { QuickConnectDialog } from './QuickConnectDialog'
|
||||
|
||||
const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' }
|
||||
|
||||
const renderDialog = (dataProvider, onClose = vi.fn()) => {
|
||||
render(
|
||||
<DataProviderContext.Provider value={dataProvider}>
|
||||
<TestContext initialState={{ admin: { ui: { optimistic: false } } }}>
|
||||
<QuickConnectDialog open={true} onClose={onClose} />
|
||||
</TestContext>
|
||||
</DataProviderContext.Provider>,
|
||||
)
|
||||
return onClose
|
||||
}
|
||||
|
||||
const enterCode = (code) => {
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: code } })
|
||||
fireEvent.click(screen.getByText('menu.quickConnect.continue'))
|
||||
}
|
||||
|
||||
describe('QuickConnectDialog', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('shows the device before approving the code', async () => {
|
||||
const dataProvider = {
|
||||
lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
|
||||
authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
|
||||
}
|
||||
const onClose = renderDialog(dataProvider)
|
||||
|
||||
enterCode('123 456')
|
||||
await screen.findByText('menu.quickConnect.approve')
|
||||
expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456')
|
||||
expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('menu.quickConnect.approve'))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456')
|
||||
})
|
||||
|
||||
it('goes back to the code input', async () => {
|
||||
const dataProvider = {
|
||||
lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
|
||||
authorizeQuickConnect: vi.fn(),
|
||||
}
|
||||
renderDialog(dataProvider)
|
||||
|
||||
enterCode('123456')
|
||||
await screen.findByText('menu.quickConnect.approve')
|
||||
fireEvent.click(screen.getByText('ra.action.back'))
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('123456')
|
||||
expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays on the code input when the code is unknown', async () => {
|
||||
const dataProvider = {
|
||||
lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }),
|
||||
authorizeQuickConnect: vi.fn(),
|
||||
}
|
||||
const onClose = renderDialog(dataProvider)
|
||||
|
||||
enterCode('000000')
|
||||
await waitFor(() =>
|
||||
expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(),
|
||||
)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.queryByText('menu.quickConnect.approve')).toBeNull()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns to the code input when approval fails', async () => {
|
||||
const dataProvider = {
|
||||
lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
|
||||
authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }),
|
||||
}
|
||||
const onClose = renderDialog(dataProvider)
|
||||
|
||||
enterCode('123456')
|
||||
fireEvent.click(await screen.findByText('menu.quickConnect.approve'))
|
||||
|
||||
await screen.findByRole('textbox')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables Continue until a code is typed', () => {
|
||||
renderDialog({ lookupQuickConnect: vi.fn() })
|
||||
expect(
|
||||
screen.getByText('menu.quickConnect.continue').closest('button'),
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
|
@ -2,4 +2,5 @@ export * from './AboutDialog'
|
|||
export * from './SelectPlaylistInput'
|
||||
export * from './ListenBrainzTokenDialog'
|
||||
export * from './SaveQueueDialog'
|
||||
export * from './QuickConnectDialog'
|
||||
export * from './Dialogs'
|
||||
|
|
|
|||
|
|
@ -591,6 +591,9 @@
|
|||
"remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.",
|
||||
"notifications_blocked": "You have blocked Notifications for this site in your browser's settings",
|
||||
"notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https",
|
||||
"quickConnectApproved": "%{app} on %{device} is now signed in",
|
||||
"quickConnectInvalidCode": "Invalid or expired code",
|
||||
"quickConnectError": "Could not approve the code",
|
||||
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
|
||||
"lastfmLinkFailure": "Last.fm could not be linked",
|
||||
"lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled",
|
||||
|
|
@ -622,6 +625,14 @@
|
|||
"none": "None"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"quickConnect": {
|
||||
"name": "Quick Connect",
|
||||
"code": "Code",
|
||||
"help": "Enter the code shown by a Jellyfin app to sign it in to your account",
|
||||
"confirm": "Sign in %{app} %{version} on %{device} to your account?",
|
||||
"continue": "Continue",
|
||||
"approve": "Approve"
|
||||
},
|
||||
"version": "Version",
|
||||
"theme": "Theme",
|
||||
"personal": {
|
||||
|
|
|
|||
14
ui/src/i18n/useDateLocale.js
Normal file
14
ui/src/i18n/useDateLocale.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useLocale } from 'react-admin'
|
||||
|
||||
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
|
||||
// as en-US. Borrow the region from the browser when it speaks the same language.
|
||||
const resolveDateLocale = (locale, browserLocales = []) => {
|
||||
if (!locale || locale.includes('-')) return locale
|
||||
const base = locale.toLowerCase()
|
||||
return (
|
||||
browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale
|
||||
)
|
||||
}
|
||||
|
||||
export const useDateLocale = () =>
|
||||
resolveDateLocale(useLocale(), navigator.languages)
|
||||
45
ui/src/i18n/useDateLocale.test.js
Normal file
45
ui/src/i18n/useDateLocale.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useDateLocale } from './useDateLocale'
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useDateLocale', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderWith = async (locale, browserLocales) => {
|
||||
const { useLocale } = await import('react-admin')
|
||||
vi.mocked(useLocale).mockReturnValue(locale)
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales)
|
||||
return renderHook(() => useDateLocale()).result
|
||||
}
|
||||
|
||||
it('adds the region from the browser when the language has none', async () => {
|
||||
const result = await renderWith('en', ['en-GB', 'fr-FR'])
|
||||
expect(result.current).toEqual('en-GB')
|
||||
})
|
||||
|
||||
it('falls back to the language when no browser entry matches', async () => {
|
||||
const result = await renderWith('de', ['en-US', 'fr-FR'])
|
||||
expect(result.current).toEqual('de')
|
||||
})
|
||||
|
||||
it('keeps a language that already carries a region or script', async () => {
|
||||
expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br')
|
||||
expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans')
|
||||
})
|
||||
|
||||
it('matches the browser language case-insensitively', async () => {
|
||||
const result = await renderWith('pt', ['PT-PT'])
|
||||
expect(result.current).toEqual('PT-PT')
|
||||
})
|
||||
|
||||
it('returns undefined when there is no language', async () => {
|
||||
const result = await renderWith(undefined, ['en-GB'])
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -6,12 +6,17 @@ import {
|
|||
usePermissions,
|
||||
getResources,
|
||||
} from 'react-admin'
|
||||
import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md'
|
||||
import {
|
||||
MdInfo,
|
||||
MdPerson,
|
||||
MdPhonelink,
|
||||
MdSupervisorAccount,
|
||||
} from 'react-icons/md'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core'
|
||||
import ViewListIcon from '@material-ui/icons/ViewList'
|
||||
import { Dialogs } from '../dialogs/Dialogs'
|
||||
import { AboutDialog } from '../dialogs'
|
||||
import { AboutDialog, QuickConnectDialog } from '../dialogs'
|
||||
import PersonalMenu from './PersonalMenu'
|
||||
import ActivityPanel from './ActivityPanel'
|
||||
import NowPlayingPanel from './NowPlayingPanel'
|
||||
|
|
@ -33,33 +38,34 @@ const useStyles = makeStyles(
|
|||
},
|
||||
)
|
||||
|
||||
const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => {
|
||||
const classes = useStyles(rest)
|
||||
const translate = useTranslate()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const DialogMenuItem = forwardRef(
|
||||
({ onClick, label, icon, dialog, ...rest }, ref) => {
|
||||
const classes = useStyles(rest)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
const handleClose = () => {
|
||||
onClick && onClick()
|
||||
setOpen(false)
|
||||
}
|
||||
const label = translate('menu.about')
|
||||
return (
|
||||
<>
|
||||
<MenuItem ref={ref} onClick={handleOpen} className={classes.root}>
|
||||
<ListItemIcon className={classes.icon}>
|
||||
<MdInfo title={label} size={24} />
|
||||
</ListItemIcon>
|
||||
{label}
|
||||
</MenuItem>
|
||||
<AboutDialog onClose={handleClose} open={open} />
|
||||
</>
|
||||
)
|
||||
})
|
||||
const handleClose = () => {
|
||||
onClick && onClick()
|
||||
setOpen(false)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
ref={ref}
|
||||
onClick={() => setOpen(true)}
|
||||
className={classes.root}
|
||||
>
|
||||
<ListItemIcon className={classes.icon}>
|
||||
{createElement(icon, { title: label, size: 24 })}
|
||||
</ListItemIcon>
|
||||
{label}
|
||||
</MenuItem>
|
||||
{createElement(dialog, { onClose: handleClose, open })}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AboutMenuItem.displayName = 'AboutMenuItem'
|
||||
DialogMenuItem.displayName = 'DialogMenuItem'
|
||||
|
||||
const settingsResources = (resource) =>
|
||||
resource.name !== 'user' &&
|
||||
|
|
@ -126,13 +132,24 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
|||
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
|
||||
<UserMenu {...rest}>
|
||||
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
||||
{config.enableQuickConnect && (
|
||||
<DialogMenuItem
|
||||
label={translate('menu.quickConnect.name')}
|
||||
icon={MdPhonelink}
|
||||
dialog={QuickConnectDialog}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
{renderUserMenuItemLink()}
|
||||
{resources
|
||||
.filter(settingsResources)
|
||||
.map((r) => renderSettingsMenuItemLink(r))}
|
||||
<Divider />
|
||||
<AboutMenuItem />
|
||||
<DialogMenuItem
|
||||
label={translate('menu.about')}
|
||||
icon={MdInfo}
|
||||
dialog={AboutDialog}
|
||||
/>
|
||||
</UserMenu>
|
||||
<Dialogs />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -33,12 +33,14 @@ vi.mock('../dialogs/Dialogs', () => ({
|
|||
}))
|
||||
vi.mock('../dialogs', () => ({
|
||||
AboutDialog: () => <div />,
|
||||
QuickConnectDialog: () => <div />,
|
||||
}))
|
||||
|
||||
describe('<AppBar />', () => {
|
||||
beforeEach(() => {
|
||||
config.devActivityPanel = true
|
||||
config.enableNowPlaying = true
|
||||
config.enableQuickConnect = false
|
||||
store = createStore(combineReducers({ activity: activityReducer }), {
|
||||
activity: { nowPlayingCount: 0 },
|
||||
})
|
||||
|
|
@ -62,4 +64,24 @@ describe('<AppBar />', () => {
|
|||
)
|
||||
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the Quick Connect menu item when enabled', () => {
|
||||
config.enableQuickConnect = true
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hides the Quick Connect menu item when disabled', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0)
|
||||
expect(screen.queryAllByText('menu.about')).not.toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { List, DateField } from '../common'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import { formatDateTime } from '../utils/formatters'
|
||||
|
||||
const UserFilter = (props) => (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
|
|
@ -18,6 +20,7 @@ const UserFilter = (props) => (
|
|||
|
||||
const UserList = (props) => {
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const locale = useDateLocale()
|
||||
|
||||
return (
|
||||
<List
|
||||
|
|
@ -31,7 +34,7 @@ const UserList = (props) => {
|
|||
<SimpleList
|
||||
primaryText={(record) => record.userName}
|
||||
secondaryText={(record) =>
|
||||
record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString()
|
||||
record.lastLoginAt && formatDateTime(record.lastLoginAt, locale)
|
||||
}
|
||||
tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ export const formatFullDate = (date, locale) => {
|
|||
return new Date(date).toLocaleDateString(locale, options)
|
||||
}
|
||||
|
||||
export const formatDateTime = (value, locale) =>
|
||||
new Date(value).toLocaleString(locale)
|
||||
|
||||
export const formatNumber = (value, locale) => {
|
||||
if (value === null || value === undefined) return '0'
|
||||
return value.toLocaleString(locale)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue