package jellyfin import ( "context" "encoding/hex" "errors" "fmt" "net" "net/http" "net/netip" "path" "strings" "sync" "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 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 serverName() string { if conf.Server.Jellyfin.ServerName != "" { return conf.Server.Jellyfin.ServerName } return fmt.Sprintf("Navidrome %s", consts.Version) } func (api *Router) serverID(ctx context.Context) string { 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 ds == nil { *cached = newServerID() return *cached } id, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): id = newServerID() if err := ds.Property().Put(ctx, 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. *cached = strings.ReplaceAll(id, "-", "") return *cached } // 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: 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(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 realIPMiddleware may have rewritten to a bare client 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() }