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.
This commit is contained in:
mithral 2026-10-12 01:04:33 -04:00
parent 6e04e957b8
commit 2bdfc6d139
2 changed files with 89 additions and 17 deletions

View file

@ -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: <video>/<audio>
// elements cannot set request headers, so a Jellyfin client appends
// ?api_key=... to the URL it plays.
mux.Handle("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
mux.Handle("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
mount("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
mount("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
// pattern is impossible; a subtree handler dispatches those instead. The
// exact /stream pattern above is more specific and still wins.
mux.Handle("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
mux.Handle("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
mount("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
mount("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
// Playback reporting. This is how a Jellyfin client tells us its resume
// position; it is written through the existing progress store.
mux.Handle("POST /Sessions/Playing", h.require(h.handlePlaying))
mux.Handle("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
mux.Handle("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
mount("POST /Sessions/Playing", h.require(h.handlePlaying))
mount("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
mount("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
// JSON 404s for the rest of each Jellyfin subtree. Without these an
// unknown /Items/... falls through to the SPA shell, because static.go's
@ -104,8 +130,11 @@ func (h *Handlers) Register(mux *http.ServeMux) {
for _, prefix := range []string{
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
} {
mux.HandleFunc(prefix, h.handleNotFound)
mount(prefix, http.HandlerFunc(h.handleNotFound))
}
// One subtree covers the whole alias: nothing outside the Jellyfin API
// lives under /emby, so anything unmounted there is a 404, not the shell.
mux.HandleFunc(embyPrefix+"/", h.handleNotFound)
}
// serverID returns a stable, per-installation server id, created on first use

View file

@ -433,3 +433,46 @@ func TestSystemInfoPublicNeedsNoAuth(t *testing.T) {
t.Errorf("incomplete discovery response: %s", body)
}
}
// ── the /emby alias ──────────────────────────────────────────────────────
// Many Jellyfin apps probe GET /emby/System/Info/Public during handshake and
// abandon discovery when it 404s, dropping the user on a manual "enter your
// server URL" screen. The alias is the same handler behind a second path, so
// the body must be identical - not merely similar.
func TestEmbyPrefixMirrorsRoot(t *testing.T) {
f := newFixture(t)
rootResp, rootBody := f.do(t, "GET", "/System/Info/Public", "", "")
aliasResp, aliasBody := f.do(t, "GET", "/emby/System/Info/Public", "", "")
if aliasResp.StatusCode != rootResp.StatusCode {
t.Fatalf("alias status = %d, root = %d", aliasResp.StatusCode, rootResp.StatusCode)
}
if aliasBody != rootBody {
t.Errorf("alias body:\n%s\nroot body:\n%s", aliasBody, rootBody)
}
// The authenticated routes must be aliased too, not just the public one:
// discovery that succeeds and then a 401 on the first real call is the same
// broken handshake.
if resp, _ := f.do(t, "GET", "/emby/Users/Me", "X-Emby-Token", f.admin); resp.StatusCode != http.StatusOK {
t.Errorf("/emby/Users/Me status = %d, want 200", resp.StatusCode)
}
}
// An unknown /emby/... path must be JSON. static.go's isAPIPath knows nothing
// about /emby, so without a 404 subtree here it would fall through to the SPA
// shell and the client would try to parse index.html as a discovery response.
func TestEmbyUnknownPathIsJSONNotShell(t *testing.T) {
f := newFixture(t)
for _, path := range []string{"/emby/Items/1/Nope", "/emby/Nope"} {
resp, body := f.do(t, "GET", path, "X-Emby-Token", f.admin)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status = %d, want 404", path, resp.StatusCode)
}
var out map[string]any
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Errorf("%s: body is not JSON (%s): %s", path, resp.Header.Get("Content-Type"), body)
}
}
}