From 2bdfc6d1396f8eecdfd5ab8b1ff0ca054add55cc Mon Sep 17 00:00:00 2001 From: mithral Date: Mon, 12 Oct 2026 01:04:33 -0400 Subject: [PATCH] Serve the Jellyfin surface under /emby as well as at the root Many Jellyfin clients probe GET /emby/System/Info/Public during their handshake. There was no /emby prefix anywhere in the tree, so that 404ed and the client abandoned discovery and fell back to its manual "enter your server URL" screen - the exact experience mDNS exists to remove. Register installs every pattern twice through a mount helper, with the same handler value both times, so the alias cannot drift from the root surface. The JSON 404 fallbacks get /emby forms too, plus one subtree handler, because static.go's isAPIPath knows only /healthz and /api/ and an unknown /emby/... path would otherwise be served the SPA shell. No new endpoint, no handler behaviour changed, and the bare root paths are untouched: Jellyfin's own clients use them. --- internal/jellyfin/jellyfin.go | 63 ++++++++++++++++++++++-------- internal/jellyfin/jellyfin_test.go | 43 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go index 6fe27bd..01da988 100644 --- a/internal/jellyfin/jellyfin.go +++ b/internal/jellyfin/jellyfin.go @@ -27,6 +27,12 @@ // requests route here first. isAPIPath in static.go does not know these // prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an // unknown /Items/... path returns JSON rather than the SPA shell. +// +// Every pattern is installed a second time under /emby, the legacy base path. +// Many Jellyfin apps probe GET /emby/System/Info/Public during handshake and +// give up on discovery when it 404s, showing their manual "enter your server +// URL" screen. It is the same handler behind both paths: an alias, never a +// second surface, so it cannot drift from the root. package jellyfin import ( @@ -36,6 +42,7 @@ import ( "encoding/hex" "log/slog" "net/http" + "strings" "sync" "github.com/mithral/vellum/internal/auth" @@ -62,41 +69,60 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers return &Handlers{db: db, log: log, a: a} } +// embyPrefix is the legacy Jellyfin base path. It is an alias for the root +// mount, not a second surface: every pattern below is installed under both. +const embyPrefix = "/emby" + // Register mounts the routes. See the package comment for why they live at the // root and why each subtree has a JSON 404 fallback. func (h *Handlers) Register(mux *http.ServeMux) { + // mount installs each pattern twice, bare and under /emby. Jellyfin's own + // clients use the bare root paths, but many real apps probe + // /emby/System/Info/Public during handshake and abandon discovery when it + // 404s. Same handler either way, so the alias cannot drift from the root. + mount := func(pattern string, handler http.Handler) { + mux.Handle(pattern, handler) + // The prefix goes on the path, not the whole pattern: "GET /Items" is + // one string and "embyGET /Items" does not parse. + method, path, hasMethod := strings.Cut(pattern, " ") + if !hasMethod { + method, path = "", pattern + } + mux.Handle(method+" "+embyPrefix+path, handler) + } + // Public discovery. - mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic) - mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName) + mount("GET /System/Info/Public", http.HandlerFunc(h.handleSystemInfoPublic)) + mount("POST /Users/AuthenticateByName", http.HandlerFunc(h.handleAuthenticateByName)) // Authenticated: users. - mux.Handle("GET /Users/Me", h.require(h.handleUserMe)) - mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID)) + mount("GET /Users/Me", h.require(h.handleUserMe)) + mount("GET /Users/{userId}", h.require(h.handleUserByID)) // Authenticated: browsing. - mux.Handle("GET /Items", h.require(h.handleItems)) - mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems)) - mux.Handle("GET /Items/{itemId}", h.require(h.handleItem)) - mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary)) - mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo)) + mount("GET /Items", h.require(h.handleItems)) + mount("GET /Users/{userId}/Items", h.require(h.handleUserItems)) + mount("GET /Items/{itemId}", h.require(h.handleItem)) + mount("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary)) + mount("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo)) // Media. These MUST accept api_key in the query string: