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.
This commit is contained in:
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user