mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-30 16:56:22 -04:00
* fix(subsonic): surface the reason a transcode decision failed
getTranscodeDecision returned a bare "failed to make transcode decision"
with no clue why, and the probe command ran ffprobe with -v quiet, so even
the server log bottomed out at "exit status 1". A user whose files had been
moved by an external tool only saw the opaque error.
ProbeAudioStream now returns a typed ProbeError that separates the file path
from the reason: ffprobe runs with -v error so its stderr diagnostic is
captured, and a missing or unreadable file is reported as "file not found"
rather than ffprobe's misleading "Invalid data found". The handler logs the
full detail (including the path) and returns the reason to the client with
the server path stripped out.
Reported-by: Tolriq (Symfonium)
* refactor(ffmpeg): use errors.AsType for ExitError match
probeErrorReason used the older var+errors.As form while the rest of the
codebase (and its sibling transcodeFailureReason) uses the generic
errors.AsType. Switch to it for consistency; behavior is unchanged.
* fix(subsonic): return error 70 when the source file is missing
A getTranscodeDecision probe failure was always reported as generic error 0.
When the source file is gone (moved or deleted out from under the DB), that is
a not-found condition, so return the standard Subsonic error 70 ("data not
found") instead — matching what the endpoint already returns for an unknown
mediaId. Files that exist but are corrupt or unreadable stay error 0.
ProbeError now wraps the underlying cause and implements Unwrap, so the handler
detects the case with errors.Is(err, fs.ErrNotExist).
* fix(ffmpeg): keep probe error paths out of client-facing reasons
Addresses review feedback on the ProbeError type: the Reason field doubled as
both the log detail and the client message, so an ffprobe launch failure (a
*os.PathError from fork/exec) could leak the ffprobe binary path to clients,
and an unexpected stat error was reduced to "file not accessible" in the log.
Split the two concerns: Reason now holds only a path-free, client-safe string
(built at construction), while Error() logs the full underlying cause. Launch
failures return a generic "could not read file" instead of the raw exec error.
SafeReason no longer does substring path-stripping (removing the empty-Path
edge case); the stripping happens once, against ffprobe's stderr.
* fix(subsonic): don't report a broken ffprobe as a missing media file
Two issues from review of the previous commit:
Code 70 was selected with errors.Is(err, fs.ErrNotExist), but a launch failure
of a deleted ffprobe binary is an *os.PathError that also wraps fs.ErrNotExist.
A server-side ffprobe problem was therefore reported to clients as a missing
media file. ProbeError now carries an explicit NotFound flag, set only on the
file-access branch, and the handler keys the code off that instead of the chain.
ffprobe can also exit 0 while yielding no audio stream (an audio-suffixed
container holding only video). That parse failure was returned unwrapped, so
clients got "internal error"; it is now wrapped in a ProbeError too.
612 lines
24 KiB
Go
612 lines
24 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
|
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Transcode endpoints", func() {
|
|
var (
|
|
router *Router
|
|
ds *tests.MockDataStore
|
|
mockTD *mockTranscodeDecision
|
|
w *httptest.ResponseRecorder
|
|
mockMFRepo *tests.MockMediaFileRepo
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo = &tests.MockMediaFileRepo{}
|
|
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
|
mockTD = &mockTranscodeDecision{}
|
|
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
|
w = httptest.NewRecorder()
|
|
})
|
|
|
|
Describe("GetTranscodeDecision", func() {
|
|
It("returns 405 for non-POST requests", func() {
|
|
r := newGetRequest("mediaId=123", "mediaType=song")
|
|
resp, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
|
Expect(w.Header().Get("Allow")).To(Equal("POST"))
|
|
})
|
|
|
|
It("returns error when mediaId is missing", func() {
|
|
r := newJSONPostRequest("mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error when mediaType is missing", func() {
|
|
r := newJSONPostRequest("mediaId=123", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error for unsupported mediaType", func() {
|
|
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("not yet supported"))
|
|
})
|
|
|
|
It("returns ErrorDataNotFound when media file does not exist", func() {
|
|
// mockMFRepo has no data set, so Get() returns model.ErrNotFound
|
|
r := newJSONPostRequest("mediaId=nonexistent&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("media file not found"))
|
|
})
|
|
|
|
It("returns error when media file retrieval fails", func() {
|
|
mockMFRepo.SetError(true)
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("error retrieving media file"))
|
|
})
|
|
|
|
It("enriches the decision error with the reason, without leaking the file path", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
|
|
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w",
|
|
&ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"})
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("failed to make transcode decision"))
|
|
Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input"))
|
|
Expect(err.Error()).ToNot(ContainSubstring("/music/secret"))
|
|
var subErr subError
|
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
|
})
|
|
|
|
It("returns ErrorDataNotFound when the source file is missing on disk", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
|
|
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w",
|
|
&ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true})
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("file not found"))
|
|
var subErr subError
|
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
|
|
})
|
|
|
|
It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
|
|
pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"}
|
|
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist)
|
|
Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue())
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
var subErr subError
|
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
|
})
|
|
|
|
It("returns error when body is empty", func() {
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error when body contains invalid JSON", func() {
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns error for invalid protocol in direct play profile", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
|
})
|
|
|
|
It("returns error for invalid comparison operator", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
|
})
|
|
|
|
It("returns error for invalid limitation name", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
|
})
|
|
|
|
It("returns error for invalid codec profile type", func() {
|
|
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
|
})
|
|
|
|
It("rejects wrong-case protocol", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
|
})
|
|
|
|
It("rejects wrong-case codec profile type", func() {
|
|
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
|
})
|
|
|
|
It("rejects wrong-case comparison operator", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
|
})
|
|
|
|
It("rejects wrong-case limitation name", func() {
|
|
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
|
})
|
|
|
|
It("returns a valid decision response", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{
|
|
MediaID: "song-1",
|
|
CanDirectPlay: true,
|
|
SourceStream: stream.Details{
|
|
Container: "mp3", Codec: "mp3", Bitrate: 320,
|
|
SampleRate: 44100, Channels: 2,
|
|
},
|
|
}
|
|
mockTD.token = "test-jwt-token"
|
|
|
|
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
resp, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
|
|
Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http"))
|
|
Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3"))
|
|
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
|
|
})
|
|
|
|
It("filters AAC from transcoding profiles", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "opus", Codec: "opus", BitRate: 128, Channels: 2, SampleRate: 48000},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true}
|
|
mockTD.token = "token"
|
|
|
|
body := `{
|
|
"transcodingProfiles": [
|
|
{"container": "aac", "audioCodec": "aac", "protocol": "http"},
|
|
{"container": "mp3", "audioCodec": "mp3", "protocol": "http"},
|
|
{"container": "m4a", "audioCodec": "aac", "protocol": "http"}
|
|
]
|
|
}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient).ToNot(BeNil())
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
|
})
|
|
|
|
It("includes transcode stream when transcoding", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{
|
|
MediaID: "song-2",
|
|
CanDirectPlay: false,
|
|
CanTranscode: true,
|
|
TargetFormat: "mp3",
|
|
TargetBitrate: 256,
|
|
TranscodeReasons: []string{"container not supported"},
|
|
SourceStream: stream.Details{
|
|
Container: "flac", Codec: "flac", Bitrate: 1000,
|
|
SampleRate: 96000, BitDepth: 24, Channels: 2,
|
|
},
|
|
TranscodeStream: &stream.Details{
|
|
Container: "mp3", Codec: "mp3", Bitrate: 256,
|
|
SampleRate: 96000, Channels: 2,
|
|
},
|
|
}
|
|
mockTD.token = "transcode-token"
|
|
|
|
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
|
|
resp, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
|
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
|
|
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
|
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
|
})
|
|
|
|
Describe("player MaxBitRate cap", func() {
|
|
withPlayer := func(r *http.Request, maxBitRate int) *http.Request {
|
|
ctx := request.WithPlayer(r.Context(), model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate})
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true}
|
|
mockTD.token = "token"
|
|
})
|
|
|
|
It("caps client MaxAudioBitrate at the player MaxBitRate when client declares none", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient).ToNot(BeNil())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("does not raise a lower client-declared limit", func() {
|
|
// Client declares 192 kbps (192000 bps); player cap is 320 — client wins.
|
|
body := `{"maxAudioBitrate":192000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("lowers a higher client-declared limit to the player cap", func() {
|
|
// Client declares 320 kbps (320000 bps); player cap is 192 — player wins.
|
|
body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 192)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
|
|
})
|
|
|
|
It("does nothing when no player is in context", func() {
|
|
body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
})
|
|
|
|
It("does nothing when player MaxBitRate is 0", func() {
|
|
body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}`
|
|
r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
|
})
|
|
})
|
|
|
|
Describe("player forced format", func() {
|
|
withForcedFormat := func(r *http.Request, format string, maxBitRate int) *http.Request {
|
|
ctx := r.Context()
|
|
ctx = request.WithTranscoding(ctx, model.Transcoding{TargetFormat: format})
|
|
if maxBitRate > 0 {
|
|
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate})
|
|
}
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
mockMFRepo.SetData(model.MediaFiles{
|
|
{ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100},
|
|
})
|
|
mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanTranscode: true}
|
|
mockTD.token = "token"
|
|
})
|
|
|
|
It("forces a supported format and clears direct play", func() {
|
|
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
|
|
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
|
|
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
|
Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty())
|
|
})
|
|
|
|
It("falls back to negotiation when the forced format is unsupported", func() {
|
|
// Forced format is opus, but the client only declares mp3 and flac.
|
|
// Should fall back to negotiating among the client's own profiles.
|
|
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
|
|
"transcodingProfiles":[
|
|
{"container":"flac","audioCodec":"flac","protocol":"http"},
|
|
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Profiles left intact for normal negotiation (forced format not applied).
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(2))
|
|
Expect(mockTD.capturedClient.DirectPlayProfiles).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("applies the maxBitRate cap on top of the forced format", func() {
|
|
// Client supports opus + mp3; forced format opus must be selected,
|
|
// and the maxBitRate cap applied on top.
|
|
body := `{"transcodingProfiles":[
|
|
{"container":"ogg","audioCodec":"opus","protocol":"http"},
|
|
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
|
r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 128)
|
|
|
|
_, err := router.GetTranscodeDecision(w, r)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
|
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
|
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
|
|
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("GetTranscodeStream", func() {
|
|
It("returns 400 when mediaId is missing", func() {
|
|
r := newGetRequest("mediaType=song", "transcodeParams=abc")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 400 when transcodeParams is missing", func() {
|
|
r := newGetRequest("mediaId=123", "mediaType=song")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 410 for invalid or mismatched token", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "123"}})
|
|
mockTD.resolveErr = stream.ErrTokenInvalid
|
|
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("returns 404 when media file not found", func() {
|
|
// mockMFRepo has no data, so Get() returns ErrNotFound
|
|
r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 410 when media file has changed (stale token)", func() {
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
|
|
mockTD.resolveErr = stream.ErrTokenStale
|
|
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token")
|
|
resp, err := router.GetTranscodeStream(w, r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(resp).To(BeNil())
|
|
Expect(w.Code).To(Equal(http.StatusGone))
|
|
})
|
|
|
|
It("builds correct StreamRequest for direct play", func() {
|
|
fakeStreamer := &fakeMediaStreamer{}
|
|
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
|
|
mockTD.resolvedReq = stream.Request{}
|
|
|
|
r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token")
|
|
_, _ = router.GetTranscodeStream(w, r)
|
|
|
|
Expect(fakeStreamer.captured).ToNot(BeNil())
|
|
Expect(fakeStreamer.captured.Format).To(BeEmpty())
|
|
Expect(fakeStreamer.captured.BitRate).To(BeZero())
|
|
Expect(fakeStreamer.captured.SampleRate).To(BeZero())
|
|
Expect(fakeStreamer.captured.BitDepth).To(BeZero())
|
|
Expect(fakeStreamer.captured.Channels).To(BeZero())
|
|
})
|
|
|
|
It("builds correct StreamRequest for transcoding", func() {
|
|
fakeStreamer := &fakeMediaStreamer{}
|
|
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
|
mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}})
|
|
mockTD.resolvedReq = stream.Request{
|
|
Format: "mp3",
|
|
BitRate: 256,
|
|
SampleRate: 44100,
|
|
BitDepth: 16,
|
|
Channels: 2,
|
|
}
|
|
|
|
r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10")
|
|
_, _ = router.GetTranscodeStream(w, r)
|
|
|
|
Expect(fakeStreamer.captured).ToNot(BeNil())
|
|
Expect(fakeStreamer.captured.Format).To(Equal("mp3"))
|
|
Expect(fakeStreamer.captured.BitRate).To(Equal(256))
|
|
Expect(fakeStreamer.captured.SampleRate).To(Equal(44100))
|
|
Expect(fakeStreamer.captured.BitDepth).To(Equal(16))
|
|
Expect(fakeStreamer.captured.Channels).To(Equal(2))
|
|
Expect(fakeStreamer.captured.Offset).To(Equal(10))
|
|
})
|
|
})
|
|
|
|
Describe("bpsToKbps", func() {
|
|
It("converts standard bitrates", func() {
|
|
Expect(bpsToKbps(128000)).To(Equal(128))
|
|
Expect(bpsToKbps(320000)).To(Equal(320))
|
|
Expect(bpsToKbps(256000)).To(Equal(256))
|
|
})
|
|
It("returns 0 for 0", func() {
|
|
Expect(bpsToKbps(0)).To(Equal(0))
|
|
})
|
|
It("rounds instead of truncating", func() {
|
|
Expect(bpsToKbps(999)).To(Equal(1))
|
|
Expect(bpsToKbps(500)).To(Equal(1))
|
|
Expect(bpsToKbps(499)).To(Equal(0))
|
|
})
|
|
It("returns 0 for negative values", func() {
|
|
Expect(bpsToKbps(-1)).To(Equal(0))
|
|
Expect(bpsToKbps(-1000)).To(Equal(0))
|
|
Expect(bpsToKbps(-1000000)).To(Equal(0))
|
|
})
|
|
})
|
|
|
|
Describe("kbpsToBps", func() {
|
|
It("converts standard bitrates", func() {
|
|
Expect(kbpsToBps(128)).To(Equal(128000))
|
|
Expect(kbpsToBps(320)).To(Equal(320000))
|
|
})
|
|
It("returns 0 for 0", func() {
|
|
Expect(kbpsToBps(0)).To(Equal(0))
|
|
})
|
|
})
|
|
|
|
Describe("convertBitrateValues", func() {
|
|
It("converts valid bps strings to kbps", func() {
|
|
Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"}))
|
|
})
|
|
It("preserves unparseable values", func() {
|
|
Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"}))
|
|
})
|
|
It("handles empty slice", func() {
|
|
Expect(convertBitrateValues([]string{})).To(Equal([]string{}))
|
|
})
|
|
})
|
|
})
|
|
|
|
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
|
|
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
|
|
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
return r
|
|
}
|
|
|
|
// mockTranscodeDecision is a test double for stream.TranscodeDecider
|
|
type mockTranscodeDecision struct {
|
|
decision *stream.TranscodeDecision
|
|
decisionErr error
|
|
token string
|
|
tokenErr error
|
|
resolvedReq stream.Request
|
|
resolveErr error
|
|
capturedClient *stream.ClientInfo
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) {
|
|
m.capturedClient = ci
|
|
if m.decisionErr != nil {
|
|
return nil, m.decisionErr
|
|
}
|
|
if m.decision != nil {
|
|
return m.decision, nil
|
|
}
|
|
return &stream.TranscodeDecision{}, nil
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) stream.Request {
|
|
return stream.Request{Format: "raw"}
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) {
|
|
return m.token, m.tokenErr
|
|
}
|
|
|
|
func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ *model.MediaFile, offset int) (stream.Request, error) {
|
|
if m.resolveErr != nil {
|
|
return stream.Request{}, m.resolveErr
|
|
}
|
|
req := m.resolvedReq
|
|
req.Offset = offset
|
|
return req, nil
|
|
}
|
|
|
|
// fakeMediaStreamer captures the StreamRequest and returns a sentinel error,
|
|
// allowing tests to verify parameter passing without constructing a real Stream.
|
|
var errStreamCaptured = errors.New("stream request captured")
|
|
|
|
type fakeMediaStreamer struct {
|
|
captured *stream.Request
|
|
}
|
|
|
|
func (f *fakeMediaStreamer) NewStream(_ context.Context, _ *model.MediaFile, req stream.Request) (*stream.Stream, error) {
|
|
f.captured = &req
|
|
return nil, errStreamCaptured
|
|
}
|