fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param

This commit is contained in:
Deluan 2026-07-05 21:06:28 -04:00
commit 0c4e6b2c70
5 changed files with 88 additions and 1 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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"))
})
})

View file

@ -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)
}
}

View file

@ -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