From 7c5aa1fafaae0b4cc3af7fe1958ad9e1cc9bf943 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 9 Mar 2026 09:43:55 -0400 Subject: [PATCH] test(e2e): add transcode endpoint e2e tests and clean up test helpers Add comprehensive e2e tests for getTranscodeDecision and getTranscodeStream endpoints covering direct play, transcoding, error handling, and round-trip token validation. Refactor buildPostReq to reuse buildReq for auth params, remove unused WAV/AAC test tracks, and consolidate duplicate test assertions. --- core/storage/storagetest/fake_storage.go | 3 + server/e2e/e2e_suite_test.go | 57 +++ server/e2e/subsonic_album_lists_test.go | 24 +- server/e2e/subsonic_browsing_test.go | 2 +- server/e2e/subsonic_multilibrary_test.go | 2 +- server/e2e/subsonic_searching_test.go | 6 +- server/e2e/subsonic_transcode_test.go | 464 +++++++++++++++++++++++ 7 files changed, 542 insertions(+), 16 deletions(-) create mode 100644 server/e2e/subsonic_transcode_test.go diff --git a/core/storage/storagetest/fake_storage.go b/core/storage/storagetest/fake_storage.go index 79ed3193..1b0d1a6c 100644 --- a/core/storage/storagetest/fake_storage.go +++ b/core/storage/storagetest/fake_storage.go @@ -284,6 +284,9 @@ func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) { p.AudioProperties.BitDepth = getInt("bitdepth") p.AudioProperties.SampleRate = getInt("samplerate") p.AudioProperties.Channels = getInt("channels") + if codec, ok := data["codec"].(string); ok { + p.AudioProperties.Codec = codec + } for k, v := range data { p.Tags[k] = []string{fmt.Sprintf("%v", v)} } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 02b66f4b..e55130ff 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -1,6 +1,7 @@ package e2e import ( + "bytes" "context" "encoding/json" "errors" @@ -55,6 +56,7 @@ type _t = map[string]any var template = storagetest.Template var track = storagetest.Track +var file = storagetest.File // MusicBrainz ID constants for test data (valid UUID v4 values) const ( @@ -122,6 +124,9 @@ func buildTestFS() storagetest.FakeFS { popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"}) + // Template for diverse-format transcode test tracks + tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), @@ -140,6 +145,33 @@ func buildTestFS() storagetest.FakeFS { "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), // CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests) "CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")), + + // Diverse audio format tracks for transcode e2e tests + "Test/Transcode Formats/01 - TC FLAC Standard.flac": file(tcBase, _t{ + "title": "TC FLAC Standard", "track": 1, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(240), + }), + "Test/Transcode Formats/02 - TC FLAC HiRes.flac": file(tcBase, _t{ + "title": "TC FLAC HiRes", "track": 2, "suffix": "flac", + "bitrate": 3000, "samplerate": 96000, "bitdepth": 24, "channels": 2, "duration": int64(180), + }), + "Test/Transcode Formats/03 - TC ALAC Track.m4a": file(tcBase, _t{ + "title": "TC ALAC Track", "track": 3, "suffix": "m4a", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(200), + }), + "Test/Transcode Formats/04 - TC DSD Track.dsf": file(tcBase, _t{ + "title": "TC DSD Track", "track": 4, "suffix": "dsf", + "bitrate": 5645, "samplerate": 2822400, "bitdepth": 1, "channels": 2, "duration": int64(300), + }), + "Test/Transcode Formats/05 - TC Opus Track.opus": file(tcBase, _t{ + "title": "TC Opus Track", "track": 5, "suffix": "opus", + "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(210), + }), + "Test/Transcode Formats/06 - TC MKA Opus.mka": file(tcBase, _t{ + "title": "TC MKA Opus", "track": 6, "suffix": "mka", "codec": "opus", + "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(220), + }), + // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -207,6 +239,30 @@ func buildReq(user model.User, endpoint string, params ...string) *http.Request return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil) } +// buildPostReq creates a POST request with a JSON body and Subsonic auth params in the query string. +func buildPostReq(user model.User, endpoint string, body string, params ...string) *http.Request { + getReq := buildReq(user, endpoint, params...) + r := httptest.NewRequest("POST", getReq.URL.RequestURI(), bytes.NewReader([]byte(body))) + r.Header.Set("Content-Type", "application/json") + return r +} + +// doPostReq makes a POST round-trip as admin and returns the parsed Subsonic response. +func doPostReq(endpoint string, body string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildPostReq(adminUser, endpoint, body, params...) + router.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doRawPostReq makes a POST round-trip as admin and returns the raw recorder. +func doRawPostReq(endpoint string, body string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildPostReq(adminUser, endpoint, body, params...) + router.ServeHTTP(w, r) + return w +} + // parseJSONResponse parses the JSON response body into a Subsonic response struct. func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { Expect(w.Code).To(Equal(http.StatusOK)) @@ -411,6 +467,7 @@ func setupTestDB() { }) conf.Server.MusicFolder = "fake:///music" conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) restoreDB() diff --git a/server/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go index d3d24a7c..d41d17db 100644 --- a/server/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(6)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,14 +27,15 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(6)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop + Expect(albums).To(HaveLen(7)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) Expect(albums[5].Title).To(Equal("Pop")) + Expect(albums[6].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -42,22 +43,23 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(6)) + Expect(albums).To(HaveLen(7)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various, then CJK: シートベルツ + // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) Expect(albums[3].Artist).To(Equal("Miles Davis")) - Expect(albums[4].Artist).To(Equal("Various")) - Expect(albums[5].Artist).To(Equal("シートベルツ")) + Expect(albums[4].Artist).To(Equal("Test Artist")) + Expect(albums[5].Artist).To(Equal("Various")) + Expect(albums[6].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(6)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) }) It("type=byGenre filters by genre parameter", func() { @@ -188,7 +190,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(6)) + Expect(albums).To(HaveLen(7)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -199,7 +201,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(6)) + Expect(resp.AlbumList2.Album).To(HaveLen(7)) }) }) @@ -244,7 +246,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.RandomSongs).ToNot(BeNil()) Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) - Expect(resp.RandomSongs.Songs).To(HaveLen(7)) + Expect(resp.RandomSongs.Songs).To(HaveLen(10)) }) It("respects size parameter", func() { diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go index 5a2da873..55aeb8e9 100644 --- a/server/e2e/subsonic_browsing_test.go +++ b/server/e2e/subsonic_browsing_test.go @@ -288,7 +288,7 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Genres).ToNot(BeNil()) - Expect(resp.Genres.Genre).To(HaveLen(3)) + Expect(resp.Genres.Genre).To(HaveLen(4)) }) It("includes correct genre names", func() { diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index aa4a0c62..f59187d0 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -141,7 +141,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(6)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } diff --git a/server/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go index bfcbbc8e..7f6aaf57 100644 --- a/server/e2e/subsonic_searching_test.go +++ b/server/e2e/subsonic_searching_test.go @@ -115,9 +115,9 @@ var _ = Describe("Search Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(5)) - Expect(resp.SearchResult3.Album).To(HaveLen(6)) - Expect(resp.SearchResult3.Song).To(HaveLen(7)) + Expect(resp.SearchResult3.Artist).To(HaveLen(6)) + Expect(resp.SearchResult3.Album).To(HaveLen(7)) + Expect(resp.SearchResult3.Song).To(HaveLen(13)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go new file mode 100644 index 00000000..16a884bf --- /dev/null +++ b/server/e2e/subsonic_transcode_test.go @@ -0,0 +1,464 @@ +package e2e + +import ( + "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) + 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(ctx).GetAll() + 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") + alacTrackID = ensureGetTrackID("TC ALAC Track") + dsdTrackID = ensureGetTrackID("TC DSD Track") + opusTrackID = ensureGetTrackID("TC Opus Track") + mkaOpusTrackID = ensureGetTrackID("TC MKA Opus") + }) + + Describe("getTranscodeDecision", func() { + 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))) + }) + }) + + 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("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(ctx).Get(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(ctx).Put(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(ctx).Put(mf)).To(Succeed()) + }) + }) + + 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(spy.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(spy.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(spy.LastRequest.Offset).To(Equal(30)) + }) + }) + }) +})