mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 10:57:08 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
691 lines
30 KiB
Go
691 lines
30 KiB
Go
package e2e
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// Client profile JSON bodies for getTranscodeDecision requests.
|
|
// All bitrate values are in bps (per OpenSubsonic spec).
|
|
const (
|
|
// mp3OnlyClient can direct-play mp3 and transcode to mp3
|
|
mp3OnlyClient = `{
|
|
"name": "test-mp3-only",
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// flacAndMp3Client can direct-play flac and mp3, transcode to mp3
|
|
flacAndMp3Client = `{
|
|
"name": "test-flac-mp3",
|
|
"directPlayProfiles": [
|
|
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]},
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// universalClient can direct-play most formats
|
|
universalClient = `{
|
|
"name": "test-universal",
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]},
|
|
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]},
|
|
{"containers": ["m4a"], "audioCodecs": ["alac", "aac"], "protocols": ["http"]},
|
|
{"containers": ["opus", "ogg"], "audioCodecs": ["opus"], "protocols": ["http"]},
|
|
{"containers": ["wav"], "audioCodecs": ["pcm"], "protocols": ["http"]},
|
|
{"containers": ["dsf"], "audioCodecs": ["dsd"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// bitrateCapClient has maxAudioBitrate set to 320000 bps (320 kbps)
|
|
bitrateCapClient = `{
|
|
"name": "test-bitrate-cap",
|
|
"maxAudioBitrate": 320000,
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]},
|
|
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// opusTranscodeClient can direct-play mp3, transcode to opus
|
|
opusTranscodeClient = `{
|
|
"name": "test-opus-transcode",
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "opus", "audioCodec": "opus", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// flacOnlyClient can direct-play flac, transcode to flac (no mp3 support at all)
|
|
flacOnlyClient = `{
|
|
"name": "test-flac-only",
|
|
"directPlayProfiles": [
|
|
{"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "flac", "audioCodec": "flac", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// maxTranscodeBitrateClient has maxTranscodingAudioBitrate set
|
|
maxTranscodeBitrateClient = `{
|
|
"name": "test-max-transcode-bitrate",
|
|
"maxTranscodingAudioBitrate": 192000,
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"}
|
|
]
|
|
}`
|
|
|
|
// dsdToFlacClient can direct-play mp3, transcode to flac
|
|
dsdToFlacClient = `{
|
|
"name": "test-dsd-to-flac",
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
|
|
],
|
|
"transcodingProfiles": [
|
|
{"container": "flac", "audioCodec": "flac", "protocol": "http"}
|
|
]
|
|
}`
|
|
)
|
|
|
|
var _ = Describe("Transcode Endpoints", Ordered, func() {
|
|
// Track IDs resolved in BeforeAll
|
|
var (
|
|
mp3TrackID string // Come Together (mp3, 320kbps)
|
|
flacTrackID string // TC FLAC Standard (flac, 900kbps)
|
|
flacHiResTrackID string // TC FLAC HiRes (flac, 3000kbps)
|
|
flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch)
|
|
alacTrackID string // TC ALAC Track (m4a, alac)
|
|
dsdTrackID string // TC DSD Track (dsf, dsd)
|
|
opusTrackID string // TC Opus Track (opus, 128kbps)
|
|
mkaOpusTrackID string // TC MKA Opus (mka, opus via codec tag)
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
setupTestDB()
|
|
|
|
songs, err := ds.MediaFile().GetAll(ctx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
byTitle := map[string]string{}
|
|
for _, s := range songs {
|
|
byTitle[s.Title] = s.ID
|
|
}
|
|
ensureGetTrackID := func(title string) string {
|
|
id := byTitle[title]
|
|
Expect(id).ToNot(BeEmpty())
|
|
return id
|
|
}
|
|
mp3TrackID = ensureGetTrackID("Come Together")
|
|
flacTrackID = ensureGetTrackID("TC FLAC Standard")
|
|
flacHiResTrackID = ensureGetTrackID("TC FLAC HiRes")
|
|
flacMultichTrackID = ensureGetTrackID("TC FLAC Multichannel")
|
|
alacTrackID = ensureGetTrackID("TC ALAC Track")
|
|
dsdTrackID = ensureGetTrackID("TC DSD Track")
|
|
opusTrackID = ensureGetTrackID("TC Opus Track")
|
|
mkaOpusTrackID = ensureGetTrackID("TC MKA Opus")
|
|
})
|
|
|
|
Describe("getTranscodeDecision", func() {
|
|
// setPlayerMaxBitRate ensures a player exists for the test-client and sets its MaxBitRate.
|
|
// It makes a dummy request to register the player, then updates it via the repository.
|
|
setPlayerMaxBitRate := func(maxBitRate int) {
|
|
doReq("ping")
|
|
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
player.MaxBitRate = maxBitRate
|
|
Expect(ds.Player().Put(ctx, player)).To(Succeed())
|
|
}
|
|
|
|
setPlayerForcedFormat := func(format string) {
|
|
doReq("ping")
|
|
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
trc, err := ds.Transcoding().FindByFormat(ctx, format)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
player.TranscodingId = trc.ID
|
|
Expect(ds.Player().Put(ctx, player)).To(Succeed())
|
|
}
|
|
|
|
AfterEach(func() {
|
|
// Reset player MaxBitRate to 0 after each test
|
|
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
|
|
if err == nil {
|
|
player.MaxBitRate = 0
|
|
player.TranscodingId = ""
|
|
_ = ds.Player().Put(ctx, player)
|
|
}
|
|
})
|
|
|
|
Describe("error cases", func() {
|
|
It("returns 405 for GET request", func() {
|
|
w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
|
})
|
|
|
|
It("returns error when mediaId is missing", func() {
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter))
|
|
})
|
|
|
|
It("returns error when mediaType is missing", func() {
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID)
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter))
|
|
})
|
|
|
|
It("returns error for unsupported mediaType", func() {
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "video")
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(responses.ErrorGeneric))
|
|
})
|
|
|
|
It("returns error for invalid JSON body", func() {
|
|
resp := doPostReq("getTranscodeDecision", "{invalid-json", "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("returns error for empty JSON body", func() {
|
|
w := doRawPostReq("getTranscodeDecision", "", "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 with error status
|
|
resp := parseJSONResponse(w)
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
})
|
|
|
|
It("returns error for non-existent media ID", func() {
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", "non-existent-id", "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound))
|
|
})
|
|
|
|
It("returns error for invalid protocol in body", func() {
|
|
invalidBody := `{
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["invalid-protocol"]}
|
|
]
|
|
}`
|
|
resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
|
|
It("returns error for invalid comparison operator in body", func() {
|
|
invalidBody := `{
|
|
"directPlayProfiles": [
|
|
{"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}
|
|
],
|
|
"codecProfiles": [{
|
|
"type": "AudioCodec", "name": "mp3",
|
|
"limitations": [{"name": "audioBitrate", "comparison": "InvalidOp", "values": ["320000"]}]
|
|
}]
|
|
}`
|
|
resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
Expect(resp.Error).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("direct play decisions", func() {
|
|
It("allows MP3 direct play when client supports mp3", func() {
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).To(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("allows FLAC direct play when client supports flac", func() {
|
|
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("allows ALAC direct play via m4a container + alac codec matching", func() {
|
|
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", alacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("allows Opus direct play when client supports opus", func() {
|
|
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", opusTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
})
|
|
|
|
It("denies direct play when container mismatches", func() {
|
|
// mp3OnlyClient cannot play FLAC container
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
})
|
|
|
|
It("denies direct play when codec mismatches", func() {
|
|
// MKA container with opus codec — client only supports mp3
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mkaOpusTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
})
|
|
|
|
It("denies direct play when maxAudioBitrate exceeded", func() {
|
|
// bitrateCapClient caps at 320kbps, FLAC is 900kbps
|
|
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("transcode decisions", func() {
|
|
It("transcodes FLAC to MP3 when client only supports MP3", func() {
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3"))
|
|
Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("transcodes FLAC hi-res to Opus with correct sample rate", func() {
|
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacHiResTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
|
// Opus always outputs 48000 Hz
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(48000)))
|
|
})
|
|
|
|
It("transcodes DSD to FLAC with normalized sample rate and bit depth", func() {
|
|
resp := doPostReq("getTranscodeDecision", dsdToFlacClient, "mediaId", dsdTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("flac"))
|
|
// DSD sample rate normalized: 2822400 / 8 = 352800
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(352800)))
|
|
// DSD 1-bit → 24-bit PCM
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitdepth).To(Equal(int32(24)))
|
|
})
|
|
|
|
It("refuses lossy to lossless transcoding: MP3 to FLAC", func() {
|
|
// flacOnlyClient can't direct-play mp3, and lossy→lossless transcode is rejected
|
|
resp := doPostReq("getTranscodeDecision", flacOnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
// MP3 is lossy, FLAC is lossless — should not allow transcoding
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeFalse())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
Expect(resp.TranscodeDecision.TranscodeParams).To(BeEmpty())
|
|
})
|
|
|
|
It("caps transcode bitrate via maxTranscodingAudioBitrate", func() {
|
|
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
// maxTranscodingAudioBitrate is 192000 bps = 192 kbps → response in bps
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
|
})
|
|
|
|
It("clamps multichannel FLAC to 2 channels when transcoding to MP3 (#5336)", func() {
|
|
// mp3OnlyClient has no MaxAudioChannels set, so this exercises the
|
|
// codec-intrinsic clamp in core/stream/codec.go (codecMaxChannels).
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacMultichTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.SourceStream.AudioChannels).To(Equal(int32(6)))
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3"))
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioChannels).To(Equal(int32(2)))
|
|
})
|
|
})
|
|
|
|
Describe("response structure", func() {
|
|
It("has correct sourceStream details", func() {
|
|
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
src := resp.TranscodeDecision.SourceStream
|
|
Expect(src).ToNot(BeNil())
|
|
Expect(src.Container).To(Equal("flac"))
|
|
Expect(src.Codec).To(Equal("flac"))
|
|
// AudioBitrate is in bps: 900 kbps * 1000 = 900000 bps
|
|
Expect(src.AudioBitrate).To(Equal(int32(900000)))
|
|
Expect(src.AudioSamplerate).To(Equal(int32(44100)))
|
|
Expect(src.AudioChannels).To(Equal(int32(2)))
|
|
Expect(src.Protocol).To(Equal("http"))
|
|
})
|
|
|
|
It("reports audioBitrate in bps (kbps * 1000)", func() {
|
|
resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
src := resp.TranscodeDecision.SourceStream
|
|
Expect(src).ToNot(BeNil())
|
|
// MP3 is 320 kbps → 320000 bps
|
|
Expect(src.AudioBitrate).To(Equal(int32(320000)))
|
|
})
|
|
})
|
|
|
|
Describe("player MaxBitRate cap is enforced", func() {
|
|
It("forces transcode when source bitrate exceeds player MaxBitRate", func() {
|
|
setPlayerMaxBitRate(320) // 320 kbps cap
|
|
|
|
// FLAC is 900kbps. Player cap (320) < source → direct play is
|
|
// rejected and the file is transcoded down.
|
|
resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
// Target bitrate is capped at the player MaxBitRate (320kbps).
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
|
})
|
|
|
|
It("uses the player cap when it is more restrictive than the client limit", func() {
|
|
setPlayerMaxBitRate(192) // 192 kbps player cap
|
|
|
|
// Client caps at 320kbps (bitrateCapClient); player is more
|
|
// restrictive at 192 → player cap wins.
|
|
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
// Player cap (192kbps) applies → 192000 bps.
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
|
})
|
|
})
|
|
|
|
Describe("format-aware default bitrate", func() {
|
|
It("uses mp3 format default (192kbps) for lossless-to-mp3 with no bitrate limits", func() {
|
|
// mp3OnlyClient has no maxAudioBitrate or maxTranscodingAudioBitrate
|
|
// FLAC → MP3 should use the mp3 default bitrate (192kbps), not hardcoded 256
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
|
// mp3 default is 192kbps = 192000 bps
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
|
})
|
|
|
|
It("uses opus format default (128kbps) for lossless-to-opus with no bitrate limits", func() {
|
|
// opusTranscodeClient has no maxAudioBitrate or maxTranscodingAudioBitrate
|
|
// FLAC → Opus should use the opus default bitrate (128kbps)
|
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
|
// opus default is 128kbps = 128000 bps
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000)))
|
|
})
|
|
|
|
It("uses maxAudioBitrate as fallback for lossless-to-lossy when no maxTranscodingAudioBitrate", func() {
|
|
// bitrateCapClient has maxAudioBitrate=320000 but no maxTranscodingAudioBitrate
|
|
// FLAC → MP3: maxAudioBitrate (320kbps) should be used as the target
|
|
resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
// maxAudioBitrate is 320kbps = 320000 bps
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
|
})
|
|
|
|
It("prefers maxTranscodingAudioBitrate over maxAudioBitrate for lossless-to-lossy", func() {
|
|
// maxTranscodeBitrateClient has maxTranscodingAudioBitrate=192000
|
|
// FLAC → MP3: should use 192kbps, not format default or maxAudioBitrate
|
|
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
// maxTranscodingAudioBitrate is 192kbps = 192000 bps
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
|
})
|
|
})
|
|
|
|
Describe("player MaxBitRate injected by getTranscodeDecision", func() {
|
|
It("injects the player cap as the transcode target when the client declares none", func() {
|
|
setPlayerMaxBitRate(320)
|
|
|
|
// opusTranscodeClient has no client bitrate limits. The player
|
|
// cap (320) is injected, so FLAC (900kbps) → opus is capped at 320.
|
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
|
// Bitrate is the player cap (320kbps), not the opus format default.
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000)))
|
|
})
|
|
|
|
It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() {
|
|
setPlayerMaxBitRate(320)
|
|
|
|
// maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps).
|
|
// Player cap (320) is higher → the lower client limit wins.
|
|
resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
// Client limit (192kbps) wins → 192000 bps.
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000)))
|
|
})
|
|
})
|
|
|
|
Describe("player forced format", func() {
|
|
It("transcodes a FLAC to the forced opus format when the client supports it", func() {
|
|
setPlayerForcedFormat("opus")
|
|
|
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
|
})
|
|
|
|
It("falls back to negotiation when the client does not support the forced format", func() {
|
|
setPlayerForcedFormat("opus")
|
|
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
|
})
|
|
|
|
It("applies maxBitRate on top of the forced format", func() {
|
|
setPlayerForcedFormat("opus")
|
|
setPlayerMaxBitRate(96)
|
|
|
|
resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus"))
|
|
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(96000)))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("getTranscodeStream", func() {
|
|
Describe("error cases", func() {
|
|
It("returns 400 when mediaId is missing", func() {
|
|
w := doRawReq("getTranscodeStream", "mediaType", "song", "transcodeParams", "some-token")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 400 when mediaType is missing", func() {
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "transcodeParams", "some-token")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 400 when transcodeParams is missing", func() {
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 400 for unsupported mediaType", func() {
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "video", "transcodeParams", "some-token")
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 410 for malformed token", func() {
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", "invalid-token")
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("returns 410 for stale token (media file updated after token issued)", func() {
|
|
// Get a valid decision token
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
token := resp.TranscodeDecision.TranscodeParams
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Save original UpdatedAt and restore after test
|
|
mf, err := ds.MediaFile().Get(ctx, mp3TrackID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
originalUpdatedAt := mf.UpdatedAt
|
|
|
|
// Update the media file's UpdatedAt to simulate a change after token issuance
|
|
mf.UpdatedAt = time.Now().Add(time.Hour)
|
|
Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed())
|
|
|
|
// Attempt to stream with the now-stale token
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token)
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
|
|
// Restore original UpdatedAt
|
|
mf.UpdatedAt = originalUpdatedAt
|
|
Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed())
|
|
})
|
|
|
|
It("returns 500 when stream creation fails", func() {
|
|
// Get a valid decision token
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
token := resp.TranscodeDecision.TranscodeParams
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Simulate streamer failure (e.g., ffmpeg missing codec)
|
|
streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'")
|
|
defer func() { streamerSpy.SimulateError = nil }()
|
|
|
|
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
It("returns 500 when transcoded stream is empty", func() {
|
|
// Get a valid decision token
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
token := resp.TranscodeDecision.TranscodeParams
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Simulate ffmpeg producing 0 bytes
|
|
streamerSpy.SimulateEmptyStream = true
|
|
defer func() { streamerSpy.SimulateEmptyStream = false }()
|
|
|
|
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
|
|
Describe("round-trip: decision then stream", func() {
|
|
It("streams direct play for MP3", func() {
|
|
// Get decision
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
token := resp.TranscodeDecision.TranscodeParams
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Stream using the token
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
// Direct play: format should be "raw" or empty
|
|
Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", ""))
|
|
})
|
|
|
|
It("streams transcoded FLAC to MP3", func() {
|
|
// Get decision
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
token := resp.TranscodeDecision.TranscodeParams
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Stream using the token
|
|
w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Format).To(Equal("mp3"))
|
|
})
|
|
|
|
It("passes offset through to stream request", func() {
|
|
// Get decision
|
|
resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song")
|
|
Expect(resp.Status).To(Equal(responses.StatusOK))
|
|
token := resp.TranscodeDecision.TranscodeParams
|
|
Expect(token).ToNot(BeEmpty())
|
|
|
|
// Stream with offset
|
|
w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song",
|
|
"transcodeParams", token, "offset", "30")
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(streamerSpy.LastRequest.Offset).To(Equal(30))
|
|
})
|
|
})
|
|
})
|
|
})
|