mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-07 18:58:38 +01:00
* fix(jellyfin): send SessionInfo on login so JellyBox gets past sign-in
JellyBox parses AuthenticateByName's SessionInfo as a required object and
fails silently when it is missing, leaving the user on the login screen.
Real Jellyfin always sends it (SessionManager.AuthenticateNewSessionInternal,
10.10.7 and master), so the login response now carries a full SessionInfo
built from the user and the MediaBrowser auth header. It includes every field
JellyBox (Id, PlayState) and Finamp (UserId, LastActivityDate, the activity
and control bools, PlayState's CanSeek/IsPaused/IsMuted) require once the
object is present. The session Id is derived from client and device id, so
repeated logins from one install share it.
* fix(jellyfin): ignore IncludeItemTypes names that aren't Jellyfin kinds
JellyBox opens an album with ParentId=<album>&IncludeItemTypes=music. Music
is not a BaseItemKind, and Jellyfin's comma-delimited binder drops values it
cannot parse, so real Jellyfin treats the request as having no type filter and
lists the album's tracks. Navidrome returned an empty list, so every album
opened empty. Entries that aren't BaseItemKind names are now dropped before
type resolution, so an all-unknown list behaves like an absent one. Real kinds
Navidrome doesn't serve, such as Boxset, still return nothing.
* fix(jellyfin): treat universal Container as the direct-play list
On /Audio/{id}/universal, Container lists the "container|codec" entries the
client can direct play, and TranscodingContainer/AudioCodec name the target
when it can't (UniversalAudioController builds DirectPlayProfiles from it).
Navidrome passed the whole list to the decider as one target format, which
matched nothing and fell back to DefaultDownsamplingFormat, so JellyBox got
every MP3 transcoded to Opus. /universal now has its own handler: a source
matching an entry keeps its format (still downsampled under a bitrate cap),
anything else is transcoded to TranscodingContainer, then AudioCodec. The
/stream routes keep treating Container as the target format.
* refactor(jellyfin): let the stream decider resolve universal requests
streamUniversal matched the Container list itself with plain string equality
and then asked the legacy resolver for the source format. That skipped the
decider's container and codec aliases (mp4 vs m4a, ogg vs opus), and a
direct-playable source over the bitrate cap was transcoded to its own format
instead of the client's TranscodingContainer.
The shared part of ResolveRequest (server-side player override, player
MaxBitRate cap, decision to Request mapping) moves to a resolve helper, and a
new ResolveClientRequest exposes it for callers that build their own
ClientInfo. streamUniversal now turns Container into DirectPlayProfiles and
TranscodingContainer/AudioCodec into a transcoding profile, so the decision
uses the same rules as the Subsonic getTranscodeDecision path. streamFile and
the /stream routes share a serveStream helper, NewSessionInfo reads the clock
itself, and duplicate comments and tests are trimmed.
449 lines
18 KiB
Go
449 lines
18 KiB
Go
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: testID("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,
|
|
lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}},
|
|
lyricsCache: newTestLyricsCache(),
|
|
}
|
|
})
|
|
|
|
Describe("getPlaybackInfo", func() {
|
|
It("returns a media source for an accessible track", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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(dto.EncodeID(testID("s1"))))
|
|
Expect(res.MediaSources[0].Container).To(Equal("mp3"))
|
|
Expect(res.MediaSources[0].Size).To(Equal(int64(1000)))
|
|
Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$"))
|
|
})
|
|
|
|
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: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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", dto.EncodeID(testID("missing")))
|
|
api.getPlaybackInfo(w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
playbackInfo := func() dto.PlaybackInfoResponse {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
api.getPlaybackInfo(w, r)
|
|
var res dto.PlaybackInfoResponse
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
return res
|
|
}
|
|
|
|
lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream {
|
|
var out []dto.MediaStream
|
|
for _, s := range res.MediaSources[0].MediaStreams {
|
|
if s.Type == "Lyric" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
|
|
testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
|
|
}}
|
|
|
|
Expect(lyricStreams(playbackInfo())).To(HaveLen(1))
|
|
})
|
|
|
|
It("advertises no Lyric stream when the pipeline finds nothing", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
|
|
Expect(lyricStreams(playbackInfo())).To(BeEmpty())
|
|
})
|
|
|
|
It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
|
|
testID("s1"): {{Kind: "main", Lang: "eng"}},
|
|
}}
|
|
|
|
Expect(lyricStreams(playbackInfo())).To(BeEmpty())
|
|
})
|
|
|
|
It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`},
|
|
})
|
|
api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{
|
|
testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}},
|
|
}}
|
|
|
|
Expect(lyricStreams(playbackInfo())).To(HaveLen(1))
|
|
})
|
|
|
|
It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() {
|
|
// Own ID: an erroring loader isn't cached, but a shared ID could still pick up
|
|
// another test's cached (non-error) result and mask this assertion.
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1},
|
|
})
|
|
api.lyrics = &fakeLyricsService{err: errors.New("boom")}
|
|
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err")))
|
|
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(dto.EncodeID(testID("s-err"))))
|
|
Expect(lyricStreams(res)).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
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: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
streamer.content = "audio-bytes"
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(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: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(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", dto.EncodeID(testID("missing")))
|
|
invoke(api.streamAudio, w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(decider.invoked).To(BeFalse())
|
|
Expect(streamer.invoked).To(BeFalse())
|
|
})
|
|
|
|
It("converts the bps audioBitRate param to kbps", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.streamAudio, w, r)
|
|
|
|
Expect(decider.req.BitRate).To(Equal(320))
|
|
})
|
|
|
|
It("uses the audioCodec param as target format when no container is given", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.streamAudio, w, r)
|
|
|
|
Expect(decider.req.Format).To(Equal("aac"))
|
|
})
|
|
|
|
It("returns 500 and logs when the streamer fails", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
streamer.err = errors.New("boom")
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.streamAudio, w, r)
|
|
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
|
|
Describe("streamUniversal", func() {
|
|
universal := func(query string) {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/universal?"+query, nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.streamUniversal, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
}
|
|
|
|
It("turns Container into direct play profiles and TranscodingContainer into the target", func() {
|
|
universal("Container=mp3,m4a|aac&TranscodingContainer=m4a&AudioCodec=aac&MaxStreamingBitrate=128000")
|
|
Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{
|
|
{Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}},
|
|
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}},
|
|
}))
|
|
Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{
|
|
{Container: "m4a", AudioCodec: "aac", Protocol: stream.ProtocolHTTP},
|
|
}))
|
|
Expect(decider.client.MaxAudioBitrate).To(Equal(128))
|
|
Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128))
|
|
})
|
|
|
|
It("uses AudioCodec as the target when no TranscodingContainer is given", func() {
|
|
universal("Container=mp3&AudioCodec=aac")
|
|
Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{
|
|
{Container: "aac", AudioCodec: "aac", Protocol: stream.ProtocolHTTP},
|
|
}))
|
|
})
|
|
|
|
It("serves the file as is for static=true", func() {
|
|
universal("static=true&Container=ogg")
|
|
Expect(decider.req.Format).To(Equal("raw"))
|
|
Expect(decider.client).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("streamHls", func() {
|
|
BeforeEach(func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1},
|
|
})
|
|
})
|
|
|
|
hls := func(query string, ctx context.Context) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx)
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.streamHls, w, r)
|
|
return w
|
|
}
|
|
|
|
It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() {
|
|
w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser())
|
|
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl"))
|
|
body := w.Body.String()
|
|
Expect(body).To(HavePrefix("#EXTM3U\n"))
|
|
Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n"))
|
|
Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n"))
|
|
Expect(body).To(ContainSubstring("#EXTINF:100.500,\n"))
|
|
Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n"))
|
|
Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n"))
|
|
})
|
|
|
|
It("omits the bitrate param when the client doesn't send one", func() {
|
|
w := hls("?audiocodec=aac&api_key=tok", ctxUser())
|
|
Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n"))
|
|
})
|
|
|
|
It("falls back to aac for codecs HLS packed-audio can't carry", func() {
|
|
w := hls("?audiocodec=opus", ctxUser())
|
|
Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n"))
|
|
})
|
|
|
|
It("honors mp3 as segment codec", func() {
|
|
w := hls("?audiocodec=mp3", ctxUser())
|
|
Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n"))
|
|
})
|
|
|
|
It("prefers the server-forced transcoding format over the requested codec", func() {
|
|
ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"})
|
|
w := hls("?audiocodec=aac", ctx)
|
|
Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n"))
|
|
})
|
|
|
|
It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() {
|
|
ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"})
|
|
w := hls("?audiocodec=aac", ctx)
|
|
Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n"))
|
|
})
|
|
|
|
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: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2},
|
|
})
|
|
Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 404 when the id doesn't match any media file", func() {
|
|
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{})
|
|
Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
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: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1},
|
|
})
|
|
streamer.content = "audio-bytes"
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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", dto.EncodeID(testID("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
|
|
// (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
|
|
client *stream.ClientInfo
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (f *fakeTranscodeDecider) ResolveClientRequest(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, offset int) stream.Request {
|
|
f.invoked = true
|
|
f.client = ci
|
|
f.req = stream.Request{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
|
|
}
|