fix(jellyfin): advertise LocalAddress in the public system info handshake

Jellify (and other @jellyfin/sdk clients) that connect by raw address fall
back to HTTP when TLS isn't available, then adopt the handshake's
LocalAddress as their server base URL. We never populated it, so Jellify's
SDK `api` object was undefined and sign-in crashed with "Cannot read
property 'configuration' of undefined" (getUserApi(api!) with an undefined
api). Over an HTTPS connection Jellify uses connectionType=hostname and
never reads LocalAddress, which is why it worked while an HTTPS proxy was
in front.

Populate LocalAddress from the request — scheme + host (honoring
X-Forwarded-*), plus the /jellyfin mount path — matching real Jellyfin,
which always sends it. Export server.ServerAddress for the resolution.
This commit is contained in:
Deluan 2026-07-06 11:13:28 -04:00
commit ac21c4e19c
3 changed files with 35 additions and 7 deletions

View file

@ -4,11 +4,13 @@ import (
"context"
"fmt"
"net/http"
"path"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
@ -49,18 +51,29 @@ func (api *Router) serverID(ctx context.Context) string {
return api.serverIDVal
}
func (api *Router) publicInfo(ctx context.Context) dto.PublicSystemInfo {
func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo {
return dto.PublicSystemInfo{
LocalAddress: localAddress(r),
ServerName: api.serverName(),
Version: jellyfinVersion,
ProductName: "Jellyfin Server",
Id: api.serverID(ctx),
Id: api.serverID(r.Context()),
StartupWizardCompleted: true,
}
}
// localAddress reconstructs the base URL the client used to reach this Jellyfin API: scheme and
// host from the request (honoring reverse-proxy X-Forwarded-* headers), plus the mount path. Real
// Jellyfin advertises this as LocalAddress; a client connecting by raw address (Jellify over HTTP,
// when TLS isn't available) adopts it as its server base URL. Without it, Jellify's SDK api
// instance is undefined and sign-in crashes ("Cannot read property 'configuration' of undefined").
func localAddress(r *http.Request) string {
scheme, host := server.ServerAddress(r)
return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI)
}
func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, api.publicInfo(r.Context()))
api.ok(w, r, api.publicInfo(r))
}
// ping answers /System/Ping with a bare plain-text server name, not a JSON-quoted string:

View file

@ -36,6 +36,21 @@ var _ = Describe("System", func() {
Expect(info.ServerName).To(HavePrefix("Navidrome"))
})
It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() {
DeferCleanup(configtest.SetupConfig())
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
r.Host = "music.example.com:4599"
api.getPublicSystemInfo(w, r)
var info dto.PublicSystemInfo
Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed())
// Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the
// SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path.
Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin"))
})
It("responds to ping with the server name as plain text", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Jellyfin.ServerName = ""

View file

@ -202,10 +202,10 @@ func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.H
func serverAddressMiddleware(h http.Handler) http.Handler {
// Define a new handler function that will be returned by this middleware function.
fn := func(w http.ResponseWriter, r *http.Request) {
// Call the serverAddress function to get the scheme and host of the server
// Call the ServerAddress function to get the scheme and host of the server
// handling the request. If a host is found, modify the request object to use
// that host and scheme instead of the original ones.
if rScheme, rHost := serverAddress(r); rHost != "" {
if rScheme, rHost := ServerAddress(r); rHost != "" {
r.Host = rHost
r.URL.Scheme = rScheme
}
@ -225,10 +225,10 @@ var (
xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")
)
// serverAddress is a helper function that returns the scheme and host of the server
// ServerAddress is a helper function that returns the scheme and host of the server
// handling the given request, as determined by the presence of X-Forwarded-* headers
// or the scheme and host of the request URL.
func serverAddress(r *http.Request) (scheme, host string) {
func ServerAddress(r *http.Request) (scheme, host string) {
// Save the original request host for later comparison.
origHost := r.Host