From 0c4e6b2c70785c8e0eb4216e86a95ad5a2e11ba1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 21:06:28 -0400 Subject: [PATCH] fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param --- server/jellyfin/api.go | 5 ++++ server/jellyfin/middlewares.go | 6 +++- server/jellyfin/middlewares_test.go | 12 ++++++++ server/jellyfin/stream.go | 22 +++++++++++++++ server/jellyfin/stream_test.go | 44 +++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 5b831855d..094930cc4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -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. diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index b2961626e..f3568ae03 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -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 { diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index d8c62d555..1dcb5fd6e 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -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")) + }) +}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index bfc674f20..e5c3cb2df 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -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) + } +} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 6cbc94651..afd04200c 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -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