mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02: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.
656 lines
26 KiB
Go
656 lines
26 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Transcode endpoints", func() {
|
|
var (
|
|
router *Router
|
|
ds *tests.MockDataStore
|
|
mockTD *mockTranscodeDecision
|
|
w *httptest.ResponseRecorder
|
|
mockMFRepo *tests.MockMediaFileRepo
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo = &tests.MockMediaFileRepo{}
|
|
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
|
mockTD = &mockTranscodeDecision{}
|
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
|
w = httptest.NewRecorder()
|
|
})
|
|
|
|
Describe("GetTranscodeDecision", func() {
|
|
It("returns 405 for non-POST requests", func() {
|
|
r := newGetRequest("mediaId=123", "mediaType=song")
|
|
resp, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
|
Expect(w.Header().Get("Allow")).To(Equal("POST"))
|
|
})
|
|
|
|
It("returns error when mediaId is missing", func() {
|
|
r := newJSONPostRequest("mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error when mediaType is missing", func() {
|
|
r := newJSONPostRequest("mediaId=123", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error for unsupported mediaType", func() {
|
|
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not yet supported"))
|
|
})
|
|
|
|
It("returns ErrorDataNotFound when media file does not exist", func() {
|
|
// mockMFRepo has no data set, so Get() returns model.ErrNotFound
|
|
r := newJSONPostRequest("mediaId=nonexistent&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("media file not found"))
|
|
})
|
|
|
|
It("returns error when media file retrieval fails", func() {
|
|
mockMFRepo.SetError(true)
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("error retrieving media file"))
|
|
})
|
|
|
|
It("enriches the decision error with the reason, without leaking the file path", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
|
|
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w",
|
|
&ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"})
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("failed to make transcode decision"))
|
|
Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input"))
|
|
Expect(err.Error()).ToNot(ContainSubstring("/music/secret"))
|
|
var subErr subError
|
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
|
})
|
|
|
|
It("returns ErrorDataNotFound when the source file is missing on disk", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
|
|
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w",
|
|
&ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true})
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("file not found"))
|
|
var subErr subError
|
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
|
|
})
|
|
|
|
It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
|
|
pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"}
|
|
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist)
|
|
Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue())
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
var subErr subError
|
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
|
})
|
|
|
|
It("returns error when body is empty", func() {
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error when body contains invalid JSON", func() {
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error for invalid protocol in direct play profile", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
|
})
|
|
|
|
It("returns error for invalid comparison operator", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
|
})
|
|
|
|
It("returns error for invalid limitation name", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
|
})
|
|
|
|
It("returns error for invalid codec profile type", func() {
|
|
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
|
})
|
|
|
|
It("rejects wrong-case protocol", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
|
})
|
|
|
|
It("rejects wrong-case codec profile type", func() {
|
|
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
|
})
|
|
|
|
It("rejects wrong-case comparison operator", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
|
})
|
|
|
|
It("rejects wrong-case limitation name", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
|
})
|
|
|
|
It("returns a valid decision response", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{
|
|
MediaID: "song-1",
|
|
CanDirectPlay: true,
|
|
SourceStream: stream.Details{
|
|
Container: "mp3", Codec: "mp3", Bitrate: 320,
|
|
SampleRate: 44100, Channels: 2,
|
|
},
|
|
}
|
|
mockTD.token = "test-jwt-token"
|
|
|
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
resp, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
|
|
Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http"))
|
|
Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3"))
|
|
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
|
|
})
|
|
|
|
It("filters AAC from transcoding profiles", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "opus", Codec: "opus", BitRate: 128, Channels: 2, SampleRate: 48000},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true}
|
|
mockTD.token = "token"
|
|
|
|
body := `{
|
|
"transcodingProfiles": [
|
|
{"container": "aac", "audioCodec": "aac", "protocol": "http"},
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"},
|
|
{"container": "m4a", "audioCodec": "aac", "protocol": "http"}
|
|
]
|
|
}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient).ToNot(BeNil())
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
|
})
|
|
|
|
It("includes transcode stream when transcoding", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{
|
|
MediaID: "song-2",
|
|
CanDirectPlay: false,
|
|
CanTranscode: true,
|
|
TargetFormat: "mp3",
|
|
TargetBitrate: 256,
|
|
TranscodeReasons: []string{"container not supported"},
|
|
SourceStream: stream.Details{
|
|
Container: "flac", Codec: "flac", Bitrate: 1000,
|
|
SampleRate: 96000, BitDepth: 24, Channels: 2,
|
|
},
|
|
TranscodeStream: &stream.Details{
|
|
Container: "mp3", Codec: "mp3", Bitrate: 256,
|
|
SampleRate: 96000, Channels: 2,
|
|
},
|
|
}
|
|
mockTD.token = "transcode-token"
|
|
|
|
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
|
|
resp, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
|
})
|
|
|
|
Describe("player MaxBitRate cap", func() {
|
|
withPlayer := func(r *http.Request, maxBitRate int) *http.Request {
|
|
ctx := request.WithPlayer(r.Context(), model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate})
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true}
|
|
mockTD.token = "token"
|
|
})
|
|
|
|
It("caps client MaxAudioBitrate at the player MaxBitRate when client declares none", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient).ToNot(BeNil())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("does not raise a lower client-declared limit", func() {
|
|
// Client declares 192 kbps (192000 bps); player cap is 320 — client wins.
|
|
body := `{"maxAudioBitrate":192000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("lowers a higher client-declared limit to the player cap", func() {
|
|
// Client declares 320 kbps (320000 bps); player cap is 192 — player wins.
|
|
body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 192)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("does nothing when no player is in context", func() {
|
|
body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("does nothing when player MaxBitRate is 0", func() {
|
|
body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
})
|
|
})
|
|
|
|
Describe("player forced format", func() {
|
|
withForcedFormat := func(r *http.Request, format string, maxBitRate int) *http.Request {
|
|
ctx := r.Context()
|
|
ctx = request.WithTranscoding(ctx, model.Transcoding{TargetFormat: format})
|
|
if maxBitRate > 0 {
|
|
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate})
|
|
}
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanTranscode: true}
|
|
mockTD.token = "token"
|
|
})
|
|
|
|
It("forces a supported format and narrows direct play to it", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
|
|
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
|
|
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
|
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
|
|
Containers: []string{"ogg"},
|
|
AudioCodecs: []string{"opus"},
|
|
Protocols: []string{"http"},
|
|
}))
|
|
})
|
|
|
|
It("falls back to negotiation when the forced format is unsupported", func() {
|
|
// Forced format is opus, but the client only declares mp3 and flac.
|
|
// Should fall back to negotiating among the client's own profiles.
|
|
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
|
|
"transcodingProfiles":[
|
|
{"container":"flac","audioCodec":"flac","protocol":"http"},
|
|
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Profiles left intact for normal negotiation (forced format not applied).
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(2))
|
|
Expect(mockTD.capturedClient.DirectPlayProfiles).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("applies the maxBitRate cap on top of the forced format", func() {
|
|
// Client supports opus + mp3; forced format opus must be selected,
|
|
// and the maxBitRate cap applied on top.
|
|
body := `{"transcodingProfiles":[
|
|
{"container":"ogg","audioCodec":"opus","protocol":"http"},
|
|
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 128)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
|
|
})
|
|
|
|
withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
|
|
ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
|
|
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
|
|
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("prefers the player maxBitRate over the transcoding default bitrate", func() {
|
|
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
|
|
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GetTranscodeStream", func() {
|
|
It("returns 400 when mediaId is missing", func() {
|
|
r := newGetRequest("mediaType=song", "transcodeParams=abc")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 400 when transcodeParams is missing", func() {
|
|
r := newGetRequest("mediaId=123", "mediaType=song")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 410 for invalid or mismatched token", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "123"}})
|
|
mockTD.resolveErr = stream.ErrTokenInvalid
|
|
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("returns 404 when media file not found", func() {
|
|
// mockMFRepo has no data, so Get() returns ErrNotFound
|
|
r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 410 when media file has changed (stale token)", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
|
|
mockTD.resolveErr = stream.ErrTokenStale
|
|
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("builds correct StreamRequest for direct play", func() {
|
|
fakeStreamer := &fakeMediaStreamer{}
|
|
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
|
|
mockTD.resolvedReq = stream.Request{}
|
|
|
|
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token")
|
|
_, _ = router.GetTranscodeStream(w, r)
|
|
|
|
Expect(fakeStreamer.captured).ToNot(BeNil())
|
|
Expect(fakeStreamer.captured.Format).To(BeEmpty())
|
|
Expect(fakeStreamer.captured.BitRate).To(BeZero())
|
|
Expect(fakeStreamer.captured.SampleRate).To(BeZero())
|
|
Expect(fakeStreamer.captured.BitDepth).To(BeZero())
|
|
Expect(fakeStreamer.captured.Channels).To(BeZero())
|
|
})
|
|
|
|
It("builds correct StreamRequest for transcoding", func() {
|
|
fakeStreamer := &fakeMediaStreamer{}
|
|
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}})
|
|
mockTD.resolvedReq = stream.Request{
|
|
Format: "mp3",
|
|
BitRate: 256,
|
|
SampleRate: 44100,
|
|
BitDepth: 16,
|
|
Channels: 2,
|
|
}
|
|
|
|
r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10")
|
|
_, _ = router.GetTranscodeStream(w, r)
|
|
|
|
Expect(fakeStreamer.captured).ToNot(BeNil())
|
|
Expect(fakeStreamer.captured.Format).To(Equal("mp3"))
|
|
Expect(fakeStreamer.captured.BitRate).To(Equal(256))
|
|
Expect(fakeStreamer.captured.SampleRate).To(Equal(44100))
|
|
Expect(fakeStreamer.captured.BitDepth).To(Equal(16))
|
|
Expect(fakeStreamer.captured.Channels).To(Equal(2))
|
|
Expect(fakeStreamer.captured.Offset).To(Equal(10))
|
|
})
|
|
})
|
|
|
|
Describe("bpsToKbps", func() {
|
|
It("converts standard bitrates", func() {
|
|
Expect(bpsToKbps(128000)).To(Equal(128))
|
|
Expect(bpsToKbps(320000)).To(Equal(320))
|
|
Expect(bpsToKbps(256000)).To(Equal(256))
|
|
})
|
|
It("returns 0 for 0", func() {
|
|
Expect(bpsToKbps(0)).To(Equal(0))
|
|
})
|
|
It("rounds instead of truncating", func() {
|
|
Expect(bpsToKbps(999)).To(Equal(1))
|
|
Expect(bpsToKbps(500)).To(Equal(1))
|
|
Expect(bpsToKbps(499)).To(Equal(0))
|
|
})
|
|
It("returns 0 for negative values", func() {
|
|
Expect(bpsToKbps(-1)).To(Equal(0))
|
|
Expect(bpsToKbps(-1000)).To(Equal(0))
|
|
Expect(bpsToKbps(-1000000)).To(Equal(0))
|
|
})
|
|
})
|
|
|
|
Describe("kbpsToBps", func() {
|
|
It("converts standard bitrates", func() {
|
|
Expect(kbpsToBps(128)).To(Equal(128000))
|
|
Expect(kbpsToBps(320)).To(Equal(320000))
|
|
})
|
|
It("returns 0 for 0", func() {
|
|
Expect(kbpsToBps(0)).To(Equal(0))
|
|
})
|
|
})
|
|
|
|
Describe("convertBitrateValues", func() {
|
|
It("converts valid bps strings to kbps", func() {
|
|
Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"}))
|
|
})
|
|
It("preserves unparseable values", func() {
|
|
Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"}))
|
|
})
|
|
It("handles empty slice", func() {
|
|
Expect(convertBitrateValues([]string{})).To(Equal([]string{}))
|
|
})
|
|
})
|
|
})
|
|
|
|
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
|
|
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
|
|
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
return r
|
|
}
|
|
|
|
// mockTranscodeDecision is a test double for stream.TranscodeDecider
|
|
type mockTranscodeDecision struct {
|
|
decision *stream.TranscodeDecision
|
|
decisionErr error
|
|
token string
|
|
tokenErr error
|
|
resolvedReq stream.Request
|
|
resolveErr error
|
|
capturedClient *stream.ClientInfo
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) {
|
|
m.capturedClient = ci
|
|
if m.decisionErr != nil {
|
|
return nil, m.decisionErr
|
|
}
|
|
if m.decision != nil {
|
|
return m.decision, nil
|
|
}
|
|
return &stream.TranscodeDecision{}, nil
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) stream.Request {
|
|
return stream.Request{Format: "raw"}
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) ResolveClientRequest(context.Context, *model.MediaFile, *stream.ClientInfo, int) stream.Request {
|
|
return stream.Request{Format: "raw"}
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) {
|
|
return m.token, m.tokenErr
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ *model.MediaFile, offset int) (stream.Request, error) {
|
|
if m.resolveErr != nil {
|
|
return stream.Request{}, m.resolveErr
|
|
}
|
|
req := m.resolvedReq
|
|
req.Offset = offset
|
|
return req, nil
|
|
}
|
|
|
|
// fakeMediaStreamer captures the StreamRequest and returns a sentinel error,
|
|
// allowing tests to verify parameter passing without constructing a real Stream.
|
|
var errStreamCaptured = errors.New("stream request captured")
|
|
|
|
type fakeMediaStreamer struct {
|
|
captured *stream.Request
|
|
}
|
|
|
|
func (f *fakeMediaStreamer) NewStream(_ context.Context, _ *model.MediaFile, req stream.Request) (*stream.Stream, error) {
|
|
f.captured = &req
|
|
return nil, errStreamCaptured
|
|
}
|