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: