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:
parent
6e04e957b8
commit
2bdfc6d139
2 changed files with 89 additions and 17 deletions
|
|
@ -27,6 +27,12 @@
|
||||||
// requests route here first. isAPIPath in static.go does not know these
|
// requests route here first. isAPIPath in static.go does not know these
|
||||||
// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an
|
// prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an
|
||||||
// unknown /Items/... path returns JSON rather than the SPA shell.
|
// 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
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -36,6 +42,7 @@ import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/mithral/vellum/internal/auth"
|
"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}
|
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
|
// Register mounts the routes. See the package comment for why they live at the
|
||||||
// root and why each subtree has a JSON 404 fallback.
|
// root and why each subtree has a JSON 404 fallback.
|
||||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
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.
|
// Public discovery.
|
||||||
mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic)
|
mount("GET /System/Info/Public", http.HandlerFunc(h.handleSystemInfoPublic))
|
||||||
mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName)
|
mount("POST /Users/AuthenticateByName", http.HandlerFunc(h.handleAuthenticateByName))
|
||||||
|
|
||||||
// Authenticated: users.
|
// Authenticated: users.
|
||||||
mux.Handle("GET /Users/Me", h.require(h.handleUserMe))
|
mount("GET /Users/Me", h.require(h.handleUserMe))
|
||||||
mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID))
|
mount("GET /Users/{userId}", h.require(h.handleUserByID))
|
||||||
|
|
||||||
// Authenticated: browsing.
|
// Authenticated: browsing.
|
||||||
mux.Handle("GET /Items", h.require(h.handleItems))
|
mount("GET /Items", h.require(h.handleItems))
|
||||||
mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems))
|
mount("GET /Users/{userId}/Items", h.require(h.handleUserItems))
|
||||||
mux.Handle("GET /Items/{itemId}", h.require(h.handleItem))
|
mount("GET /Items/{itemId}", h.require(h.handleItem))
|
||||||
mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary))
|
mount("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary))
|
||||||
mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo))
|
mount("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo))
|
||||||
|
|
||||||
// Media. These MUST accept api_key in the query string: <video>/<audio>
|
// Media. These MUST accept api_key in the query string: <video>/<audio>
|
||||||
// elements cannot set request headers, so a Jellyfin client appends
|
// elements cannot set request headers, so a Jellyfin client appends
|
||||||
// ?api_key=... to the URL it plays.
|
// ?api_key=... to the URL it plays.
|
||||||
mux.Handle("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
|
mount("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
|
||||||
mux.Handle("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
|
mount("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
|
||||||
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
|
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
|
||||||
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
|
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
|
||||||
// pattern is impossible; a subtree handler dispatches those instead. The
|
// pattern is impossible; a subtree handler dispatches those instead. The
|
||||||
// exact /stream pattern above is more specific and still wins.
|
// exact /stream pattern above is more specific and still wins.
|
||||||
mux.Handle("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
|
mount("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
|
||||||
mux.Handle("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
|
mount("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
|
||||||
|
|
||||||
// Playback reporting. This is how a Jellyfin client tells us its resume
|
// Playback reporting. This is how a Jellyfin client tells us its resume
|
||||||
// position; it is written through the existing progress store.
|
// position; it is written through the existing progress store.
|
||||||
mux.Handle("POST /Sessions/Playing", h.require(h.handlePlaying))
|
mount("POST /Sessions/Playing", h.require(h.handlePlaying))
|
||||||
mux.Handle("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
|
mount("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
|
||||||
mux.Handle("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
|
mount("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
|
||||||
|
|
||||||
// JSON 404s for the rest of each Jellyfin subtree. Without these an
|
// JSON 404s for the rest of each Jellyfin subtree. Without these an
|
||||||
// unknown /Items/... falls through to the SPA shell, because static.go's
|
// 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{
|
for _, prefix := range []string{
|
||||||
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
|
"/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
|
// serverID returns a stable, per-installation server id, created on first use
|
||||||
|
|
|
||||||
|
|
@ -433,3 +433,46 @@ func TestSystemInfoPublicNeedsNoAuth(t *testing.T) {
|
||||||
t.Errorf("incomplete discovery response: %s", body)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue