mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-28 14:17:10 +01:00
fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param
This commit is contained in:
parent
4e277b1929
commit
0c4e6b2c70
5 changed files with 88 additions and 1 deletions
|
|
@ -89,6 +89,11 @@ func (api *Router) routes() http.Handler {
|
|||
r.Get("/Audio/{itemId}/universal", api.streamAudio)
|
||||
r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
|
||||
r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
|
||||
// Direct-file endpoints: some clients (e.g. Finamp's just_audio) fetch playback audio
|
||||
// here instead of /Audio/{id}/stream after PlaybackInfo; /Download reuses the same
|
||||
// direct-play handler since Jellyfin serves the same original file for both.
|
||||
r.Get("/Items/{itemId}/File", api.streamFile)
|
||||
r.Get("/Items/{itemId}/Download", api.streamFile)
|
||||
|
||||
// Playback reports carry only the caller's own play data (see reportPlaybackStart
|
||||
// doc comment), so no library-access gate is needed here.
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ func tokenFromRequest(r *http.Request) string {
|
|||
if t := parseEmbyAuth(r).Token; t != "" {
|
||||
return t
|
||||
}
|
||||
return r.URL.Query().Get("api_key")
|
||||
if t := r.URL.Query().Get("api_key"); t != "" {
|
||||
return t
|
||||
}
|
||||
// Finamp's just_audio engine fetches direct-file URLs with ?ApiKey= (PascalCase).
|
||||
return r.URL.Query().Get("ApiKey")
|
||||
}
|
||||
|
||||
func (api *Router) authenticate(next http.Handler) http.Handler {
|
||||
|
|
|
|||
|
|
@ -78,3 +78,15 @@ var _ = Describe("authenticate middleware", func() {
|
|||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("tokenFromRequest", func() {
|
||||
It("accepts the lowercase api_key query param", func() {
|
||||
r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil)
|
||||
Expect(tokenFromRequest(r)).To(Equal("tok123"))
|
||||
})
|
||||
|
||||
It("accepts the PascalCase ApiKey query param, as sent by Finamp/just_audio", func() {
|
||||
r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil)
|
||||
Expect(tokenFromRequest(r)).To(Equal("tok123"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -92,3 +92,25 @@ func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) {
|
|||
log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// streamFile serves /Items/{itemId}/File and /Items/{itemId}/Download, Jellyfin's direct-file
|
||||
// endpoints. Real Jellyfin returns the original media file unmodified here; some clients (e.g.
|
||||
// Finamp's just_audio engine) fetch playback audio from this URL instead of /Audio/{id}/stream,
|
||||
// so it must always resolve to direct play ("raw"), never a forced transcode.
|
||||
func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) {
|
||||
mf, ok := api.mediaFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0)
|
||||
s, err := api.streamer.NewStream(ctx, mf, streamReq)
|
||||
if err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
if _, err := s.Serve(ctx, w, r); err != nil {
|
||||
log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,50 @@ var _ = Describe("Stream", func() {
|
|||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("streamFile", func() {
|
||||
It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() {
|
||||
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
|
||||
})
|
||||
streamer.content = "audio-bytes"
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.streamFile(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(decider.invoked).To(BeTrue())
|
||||
Expect(decider.req.Format).To(Equal("raw"))
|
||||
Expect(streamer.invoked).To(BeTrue())
|
||||
Expect(w.Body.String()).To(Equal("audio-bytes"))
|
||||
})
|
||||
|
||||
It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() {
|
||||
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.streamFile(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(decider.invoked).To(BeFalse())
|
||||
Expect(streamer.invoked).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "missing")
|
||||
api.streamFile(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(decider.invoked).To(BeFalse())
|
||||
Expect(streamer.invoked).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue