mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-09 03:38:48 +01:00
* feat(jellyfin): compute the address advertised by auto-discovery * fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address * feat(jellyfin): answer LAN auto-discovery broadcasts * test(jellyfin): e2e check that auto-discovery matches the public server identity * feat(jellyfin): add opt-in AutoDiscovery option and start the listener * fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures * refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored. * refactor(jellyfin): run auto-discovery as its own service in the main errgroup Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot. * refactor(jellyfin): build Discovery through wire and drop the serverName forwarder startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly. * fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is. * docs(jellyfin): note which address auto-discovery advertises on restricted binds Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is.
159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
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(ctx).Get(consts.JellyfinServerIDKey)
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
id = newServerID()
|
|
if err := 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.
|
|
*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()
|
|
}
|
|
|
|
func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) {
|
|
api.ok(w, r, false)
|
|
}
|