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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}
@ -89,6 +91,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)

View file

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

View file

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

View file

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

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

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

View file

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