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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue