mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Finamp's connection test GETs /System/Endpoint and treats anything other than a 200 carrying an IsInNetwork key as "not a Jellyfin server", so the test failed against Navidrome and dual-connection setups could never switch to the local address. IsInNetwork mirrors Jellyfin's default LAN set (NetworkManager with no LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. Notably that set omits 169.254.0.0/16, so Go's IsLinkLocalUnicast is deliberately restricted to its IPv6 half. IsLocal mirrors HttpContext.IsLocal(): the caller shares the connection's local address, not merely "is loopback". It falls back to the loopback check when the local address is unavailable.
158 lines
5.4 KiB
Go
158 lines
5.4 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server"
|
|
"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"
|
|
|
|
func (api *Router) 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
|
|
}
|
|
if api.ds == nil {
|
|
api.serverIDVal = newServerID()
|
|
return api.serverIDVal
|
|
}
|
|
id, err := api.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 {
|
|
log.Error(ctx, "Jellyfin API: could not persist server id", err)
|
|
return id
|
|
}
|
|
case err != nil:
|
|
log.Error(ctx, "Jellyfin API: could not read server id", err)
|
|
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
|
|
}
|
|
|
|
// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")).
|
|
func newServerID() string {
|
|
u := uuid.New()
|
|
return hex.EncodeToString(u[:])
|
|
}
|
|
|
|
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(r.Context()),
|
|
StartupWizardCompleted: true,
|
|
}
|
|
}
|
|
|
|
// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring
|
|
// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as
|
|
// its server base URL; without it its SDK api instance is undefined and sign-in crashes.
|
|
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))
|
|
}
|
|
|
|
func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) {
|
|
api.ok(w, r, dto.SystemInfo{
|
|
PublicSystemInfo: api.publicInfo(r),
|
|
SupportsLibraryMonitor: true,
|
|
})
|
|
}
|
|
|
|
// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's
|
|
// server does this and clients parse the raw body.
|
|
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()))
|
|
}
|
|
|
|
// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a
|
|
// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server".
|
|
func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) {
|
|
remote := remoteIP(r)
|
|
api.ok(w, r, dto.EndPointInfo{
|
|
IsLocal: isSameMachine(r, remote),
|
|
IsInNetwork: isInLocalNetwork(remote),
|
|
})
|
|
}
|
|
|
|
// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no
|
|
// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10.
|
|
func isInLocalNetwork(ip netip.Addr) bool {
|
|
return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast())
|
|
}
|
|
|
|
// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local
|
|
// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback.
|
|
func isSameMachine(r *http.Request, remote netip.Addr) bool {
|
|
local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr)
|
|
if !ok {
|
|
return remote.IsLoopback()
|
|
}
|
|
return parseIP(local.String()) == remote
|
|
}
|
|
|
|
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
|
|
func remoteIP(r *http.Request) netip.Addr {
|
|
return parseIP(r.RemoteAddr)
|
|
}
|
|
|
|
func parseIP(addr string) netip.Addr {
|
|
if h, _, err := net.SplitHostPort(addr); err == nil {
|
|
addr = h
|
|
}
|
|
ip, err := netip.ParseAddr(addr)
|
|
if err != nil {
|
|
return netip.Addr{}
|
|
}
|
|
return ip.Unmap()
|
|
}
|
|
|
|
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
|
|
api.ok(w, r, false)
|
|
}
|