fix(jellyfin): emit TranscodingUrl without the /jellyfin base path (#6089)

PlaybackInfo returned a TranscodingUrl prefixed with the /jellyfin mount path.
Clients concatenate that value onto a server base URL that already carries the
prefix, producing /jellyfin/jellyfin/Audio/{id}/universal and a 404, so playback
never started. Jellify, jellyfin-web, jellyfin-vue and Streamyfin all consume the
field this way; real Jellyfin emits it server-relative (StreamInfo.ToUrl is called
with a nil baseUrl).

Emit the path server-relative to match. Finamp is unaffected: it builds its own
stream URLs and never reads the field.
This commit is contained in:
Deluan Quintão 2026-09-05 21:44:03 -04:00 • committed by GitHub
commit 546302576a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 8 additions and 13 deletions

View file

@ -113,14 +113,12 @@ var _ = Describe("Streaming", func() {
var info dto.PlaybackInfoResponse
parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info)
streamURL := info.MediaSources[0].TranscodingUrl
// The URL includes the /jellyfin mount prefix so a client resolving it as an absolute
// host path hits the mounted router.
Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal"))
// Server-relative: clients append it to a base URL already carrying /jellyfin.
Expect(streamURL).To(HavePrefix("/Audio/" + enc(id) + "/universal"))
Expect(streamURL).ToNot(HavePrefix(consts.URLPathJellyfinAPI))
Expect(streamURL).To(ContainSubstring("api_key="))
// The embedded api_key alone must authenticate the stream — no auth header sent. The e2e
// router is mounted at the root, so strip the /jellyfin prefix before replaying.
replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI)
w := rawReq("GET", replayURL, "")
// The embedded api_key alone must authenticate the stream — no auth header sent.
w := rawReq("GET", streamURL, "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastMediaFile.ID).To(Equal(id))
})

View file

@ -10,7 +10,6 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -57,13 +56,11 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) {
})
}
}
// Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl
// verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients
// (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so
// a client resolving it as an absolute host path still hits the mounted router.
// Self-authenticating: native players fetch this without an auth header. Server-relative:
// clients append it to a base URL already carrying /jellyfin.
if token := tokenFromRequest(r); token != "" {
src.TranscodingSubProtocol = "http"
src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token)
src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token)
}
api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)})
}