mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-29 14:37:49 +01:00
feat(jellyfin): audio streaming and PlaybackInfo with library access control
This commit is contained in:
parent
926a36acfa
commit
7e26f1fa8c
3 changed files with 278 additions and 0 deletions
|
|
@ -72,6 +72,12 @@ func (api *Router) routes() http.Handler {
|
|||
r.Get("/Artists/AlbumArtists", api.getArtists)
|
||||
r.Get("/Genres", api.getGenres)
|
||||
r.Get("/MusicGenres", api.getGenres)
|
||||
|
||||
r.Get("/Audio/{itemId}/stream", api.streamAudio)
|
||||
r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio)
|
||||
r.Get("/Audio/{itemId}/universal", api.streamAudio)
|
||||
r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
|
||||
r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
|
||||
})
|
||||
|
||||
return r
|
||||
|
|
|
|||
94
server/jellyfin/stream.go
Normal file
94
server/jellyfin/stream.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// mediaFileForRequest resolves the {itemId} path param to a MediaFile and verifies the
|
||||
// current user has access to its library, writing a 404 response (never 403, to avoid an
|
||||
// existence oracle) and returning ok=false if either check fails. Shared by getPlaybackInfo
|
||||
// and streamAudio so a guessed id can't be used to probe -- or stream -- another library's
|
||||
// content, mirroring the same gate applied to getItem in items.go.
|
||||
func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) {
|
||||
ctx := r.Context()
|
||||
id := chi.URLParam(r, "itemId")
|
||||
mf, err := api.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return nil, false
|
||||
}
|
||||
u, _ := request.UserFrom(ctx)
|
||||
if !u.HasLibraryAccess(mf.LibraryID) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return nil, false
|
||||
}
|
||||
return mf, true
|
||||
}
|
||||
|
||||
// getPlaybackInfo answers POST/GET /Items/{itemId}/PlaybackInfo with a single MediaSource
|
||||
// describing direct playback of the source file. Actual format negotiation happens at stream
|
||||
// time in streamAudio, mirroring how the Subsonic API defers that decision to /stream.
|
||||
func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) {
|
||||
mf, ok := api.mediaFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
src := dto.MediaSourceInfo{
|
||||
Id: mf.ID,
|
||||
Protocol: "Http",
|
||||
Container: mf.Suffix,
|
||||
Size: mf.Size,
|
||||
Name: mf.Title,
|
||||
Type: "Default",
|
||||
RunTimeTicks: dto.TicksFromSeconds(mf.Duration),
|
||||
SupportsDirectPlay: true,
|
||||
SupportsDirectStream: true,
|
||||
SupportsTranscoding: true,
|
||||
}
|
||||
api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID})
|
||||
}
|
||||
|
||||
// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal,
|
||||
// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint.
|
||||
func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) {
|
||||
mf, ok := api.mediaFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
|
||||
format := p.StringOr("container", "")
|
||||
if format == "" {
|
||||
// The /stream.{container} route form carries the format as a path segment,
|
||||
// not a query param.
|
||||
format = chi.URLParam(r, "container")
|
||||
}
|
||||
if p.BoolOr("static", false) {
|
||||
format = "raw"
|
||||
}
|
||||
|
||||
bitRate := p.IntOr("audioBitRate", 0)
|
||||
if bitRate == 0 {
|
||||
// maxStreamingBitrate is bits/sec by Jellyfin convention; ResolveRequest expects kbps.
|
||||
bitRate = p.IntOr("maxStreamingBitrate", 0) / 1000
|
||||
}
|
||||
|
||||
streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0)
|
||||
s, err := api.streamer.NewStream(ctx, mf, streamReq)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
if _, err := s.Serve(ctx, w, r); err != nil {
|
||||
log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err)
|
||||
}
|
||||
}
|
||||
178
server/jellyfin/stream_test.go
Normal file
178
server/jellyfin/stream_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Stream", func() {
|
||||
var api *Router
|
||||
var ds *tests.MockDataStore
|
||||
var streamer *fakeMediaStreamer
|
||||
var decider *fakeTranscodeDecider
|
||||
|
||||
// alice has access to library 1 only.
|
||||
ctxUser := func() context.Context {
|
||||
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
streamer = &fakeMediaStreamer{}
|
||||
decider = &fakeTranscodeDecider{}
|
||||
api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider}
|
||||
})
|
||||
|
||||
Describe("getPlaybackInfo", func() {
|
||||
It("returns a media source for an accessible track", func() {
|
||||
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.getPlaybackInfo(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var res dto.PlaybackInfoResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
||||
Expect(res.MediaSources).To(HaveLen(1))
|
||||
Expect(res.MediaSources[0].Id).To(Equal("s1"))
|
||||
Expect(res.MediaSources[0].Container).To(Equal("mp3"))
|
||||
Expect(res.PlaySessionId).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns 404 for a track in a library the user can't access", func() {
|
||||
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.getPlaybackInfo(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 404 when the id doesn't match any media file", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "missing")
|
||||
api.getPlaybackInfo(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("streamAudio", func() {
|
||||
It("invokes the transcode decider and 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", "/Audio/s1/stream", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.streamAudio(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(decider.invoked).To(BeTrue())
|
||||
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", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.streamAudio(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", "/Audio/missing/stream", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "missing")
|
||||
api.streamAudio(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(decider.invoked).To(BeFalse())
|
||||
Expect(streamer.invoked).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns 500 and logs when the streamer fails", func() {
|
||||
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1},
|
||||
})
|
||||
streamer.err = errors.New("boom")
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser())
|
||||
r = withChiURLParam(r, "itemId", "s1")
|
||||
api.streamAudio(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether
|
||||
// (and how) ResolveRequest was invoked, so tests can assert it's never called on the
|
||||
// access-denied path, without needing a real transcode decision pipeline.
|
||||
type fakeTranscodeDecider struct {
|
||||
invoked bool
|
||||
req stream.Request
|
||||
}
|
||||
|
||||
func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) {
|
||||
return &stream.TranscodeDecision{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) {
|
||||
return stream.Request{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
|
||||
f.invoked = true
|
||||
f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset}
|
||||
return f.req
|
||||
}
|
||||
|
||||
// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether
|
||||
// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed
|
||||
// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code.
|
||||
type fakeMediaStreamer struct {
|
||||
invoked bool
|
||||
content string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) {
|
||||
f.invoked = true
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue