🏡 index : quine-global/quintodrome.git

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