diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index af2dab647..cc38dd9de 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -27,11 +27,12 @@ type TranscodeOptions struct {
Command string // DB command template (used to detect custom vs default)
Format string // Target format (mp3, opus, aac, flac)
FilePath string
- BitRate int // kbps, 0 = codec default
- SampleRate int // 0 = no constraint
- Channels int // 0 = no constraint
- BitDepth int // 0 = no constraint; valid values: 16, 24, 32
- Offset int // seconds
+ BitRate int // kbps, 0 = codec default
+ SampleRate int // 0 = no constraint
+ Channels int // 0 = no constraint
+ BitDepth int // 0 = no constraint; valid values: 16, 24, 32
+ Offset int // seconds
+ Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
@@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
} else {
args = buildTemplateArgs(opts)
}
- return e.start(ctx, args)
+ out, err := e.start(ctx, args)
+ if err != nil {
+ return nil, err
+ }
+ return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 0fa3de111..dbc8fa3c8 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -3,6 +3,7 @@ package ffmpeg
import (
"context"
"errors"
+ "io"
"os"
"os/exec"
"path/filepath"
@@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() {
})
Expect(err).To(MatchError(context.Canceled))
})
+
+ It("fills in total_samples on a piped FLAC transcode", func() {
+ stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
+ Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
+ Format: "flac",
+ FilePath: "tests/fixtures/test.flac",
+ Duration: 1, // the fixture is exactly 1s at 44100Hz
+ })
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ out, err := io.ReadAll(stream)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(out[:4])).To(Equal("fLaC"))
+ Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
+ })
+
+ It("patches the duration net of the requested offset", func() {
+ // The command has no %t, so ffmpeg still emits the whole fixture.
+ // What is under test is the header arithmetic, not the audio.
+ stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
+ Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
+ Format: "flac",
+ FilePath: "tests/fixtures/test.flac",
+ Duration: 3,
+ Offset: 1,
+ })
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ out, err := io.ReadAll(stream)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
+ })
})
Context("stderr capture", func() {
diff --git a/core/ffmpeg/flac_streaminfo.go b/core/ffmpeg/flac_streaminfo.go
new file mode 100644
index 000000000..878c28718
--- /dev/null
+++ b/core/ffmpeg/flac_streaminfo.go
@@ -0,0 +1,66 @@
+package ffmpeg
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "io"
+ "math"
+)
+
+const (
+ flacPrefixLen = 26 // through the last total_samples byte
+ flacMaxTotalSamples = 1<<36 - 1
+)
+
+// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
+// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
+func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
+ if duration <= 0 {
+ return r
+ }
+ return &flacPatcher{ReadCloser: r, duration: duration}
+}
+
+type flacPatcher struct {
+ io.ReadCloser
+ duration float32
+ // Peeking here rather than in the constructor keeps Transcode from blocking
+ // until ffmpeg has emitted its first bytes.
+ stream io.Reader
+}
+
+func (f *flacPatcher) Read(p []byte) (int, error) {
+ if f.stream == nil {
+ prefix := make([]byte, flacPrefixLen)
+ n, err := io.ReadFull(f.ReadCloser, prefix)
+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
+ return 0, err
+ }
+ prefix = prefix[:n]
+ if err == nil {
+ setFLACTotalSamples(prefix, f.duration)
+ }
+ f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
+ }
+ return f.stream.Read(p)
+}
+
+// setFLACTotalSamples takes the rate from the header rather than the transcode
+// options, so a resampled (-ar) output still gets the right count.
+func setFLACTotalSamples(prefix []byte, duration float32) {
+ if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
+ return
+ }
+ // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
+ info := binary.BigEndian.Uint64(prefix[18:])
+ rate := info >> 44
+ if rate == 0 || info&flacMaxTotalSamples != 0 {
+ return
+ }
+ total := math.Round(float64(duration) * float64(rate))
+ if total > flacMaxTotalSamples {
+ return
+ }
+ binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
+}
diff --git a/core/ffmpeg/flac_streaminfo_test.go b/core/ffmpeg/flac_streaminfo_test.go
new file mode 100644
index 000000000..6bf3503d7
--- /dev/null
+++ b/core/ffmpeg/flac_streaminfo_test.go
@@ -0,0 +1,142 @@
+package ffmpeg
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "os"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Decoded independently so the specs do not mirror the production bit-twiddling.
+func readSampleRate(b []byte) int {
+ return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
+}
+
+func readTotalSamples(b []byte) uint64 {
+ return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
+}
+
+var _ = Describe("patchFLACDuration", func() {
+ var fileFLAC []byte
+
+ // Zeroing total_samples reproduces what a piped transcode emits.
+ pipedFLAC := func() []byte {
+ b := bytes.Clone(fileFLAC)
+ b[21] &= 0xF0
+ clear(b[22:26])
+ return b
+ }
+
+ readAll := func(in []byte, duration float32) []byte {
+ out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
+ Expect(err).ToNot(HaveOccurred())
+ return out
+ }
+
+ BeforeEach(func() {
+ var err error
+ fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
+ })
+
+ It("fills in total_samples from the duration", func() {
+ out := readAll(pipedFLAC(), 1.0)
+ Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
+ })
+
+ It("takes the sample rate from the header, not from the source file", func() {
+ in := pipedFLAC()
+ // Rewrite the header's rate to 48000, as -ar would.
+ in[18], in[19] = 0x0B, 0xB8
+ in[20] &= 0x0F
+
+ out := readAll(in, 2.0)
+
+ Expect(readSampleRate(out)).To(Equal(48000))
+ Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
+ })
+
+ It("rounds to the nearest sample rather than truncating", func() {
+ // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
+ out := readAll(pipedFLAC(), 0.7)
+ Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
+ })
+
+ It("passes through when the duration overflows the 36-bit field", func() {
+ in := pipedFLAC()
+ Expect(readAll(in, 2e6)).To(Equal(in))
+ })
+
+ It("leaves everything after the header untouched", func() {
+ in := pipedFLAC()
+ out := readAll(in, 1.0)
+ Expect(out).To(HaveLen(len(in)))
+ Expect(out[26:]).To(Equal(in[26:]))
+ Expect(out[:18]).To(Equal(in[:18]))
+ })
+
+ It("leaves an already-populated total_samples alone", func() {
+ out := readAll(fileFLAC, 99.0)
+ Expect(out).To(Equal(fileFLAC))
+ })
+
+ It("passes through a stream that is not FLAC", func() {
+ in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
+ Expect(readAll(in, 1.0)).To(Equal(in))
+ })
+
+ It("passes through when the first metadata block is not STREAMINFO", func() {
+ in := pipedFLAC()
+ in[4] = 0x04 // VORBIS_COMMENT
+ Expect(readAll(in, 1.0)).To(Equal(in))
+ })
+
+ It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
+ in := pipedFLAC()[:20]
+ Expect(readAll(in, 1.0)).To(Equal(in))
+ })
+
+ It("passes through an empty stream", func() {
+ Expect(readAll(nil, 1.0)).To(BeEmpty())
+ })
+
+ It("passes through when the duration is zero or negative", func() {
+ in := pipedFLAC()
+ Expect(readAll(in, 0)).To(Equal(in))
+ Expect(readAll(in, -5)).To(Equal(in))
+ })
+
+ It("passes through when the header declares no sample rate", func() {
+ in := pipedFLAC()
+ in[18], in[19] = 0, 0
+ in[20] &= 0x0F
+ Expect(readAll(in, 1.0)).To(Equal(in))
+ })
+
+ It("propagates a read error from the underlying stream", func() {
+ _, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
+ bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
+ Expect(err).To(MatchError("boom"))
+ })
+
+ It("closes the underlying stream", func() {
+ c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
+ Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
+ Expect(c.closed).To(BeTrue())
+ })
+})
+
+type errReader struct{}
+
+func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
+
+type closeSpy struct {
+ io.Reader
+ closed bool
+}
+
+func (c *closeSpy) Close() error { c.closed = true; return nil }
diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go
index 577207636..01fef1249 100644
--- a/core/stream/decider_test.go
+++ b/core/stream/decider_test.go
@@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() {
})
})
+ Context("Player-forced format", func() {
+ symfonium := func() *ClientInfo {
+ return &ClientInfo{
+ Name: "Symfonium",
+ DirectPlayProfiles: []DirectPlayProfile{
+ {Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}},
+ },
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ }
+
+ It("direct plays a flac source forced to flac", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
+ ci := symfonium()
+ Expect(ci.ForceFormat("flac")).To(BeTrue())
+
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("still transcodes a 24-bit flac when the client caps bit depth", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
+ ci := symfonium()
+ ci.CodecProfiles = []CodecProfile{{
+ Type: CodecProfileTypeAudio, Name: "flac",
+ Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}},
+ }}
+ Expect(ci.ForceFormat("flac")).To(BeTrue())
+
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
+ })
+
+ It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
+ ci := symfonium()
+ Expect(ci.ForceFormat("mp3")).To(BeTrue())
+ ci.CapBitrate(192)
+
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetBitrate).To(Equal(192))
+ })
+
+ It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
+ ci := symfonium()
+ Expect(ci.ForceFormat("mp3")).To(BeTrue())
+ ci.CapBitrate(192)
+
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeTrue())
+ })
+
+ It("transcodes a flac source forced to mp3", func() {
+ mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
+ ci := symfonium()
+ Expect(ci.ForceFormat("mp3")).To(BeTrue())
+
+ decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(decision.CanDirectPlay).To(BeFalse())
+ Expect(decision.CanTranscode).To(BeTrue())
+ Expect(decision.TargetFormat).To(Equal("mp3"))
+ })
+ })
})
Describe("ensureProbed", func() {
diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go
index aaa3126b4..6db2f6338 100644
--- a/core/stream/media_streamer.go
+++ b/core/stream/media_streamer.go
@@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache {
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
+ Duration: job.mf.Duration,
})
if err != nil {
release()
diff --git a/core/stream/types.go b/core/stream/types.go
index 19474dd91..300017c01 100644
--- a/core/stream/types.go
+++ b/core/stream/types.go
@@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
return changed
}
-// ForceFormat narrows the client to transcoding to targetFormat and suppresses
-// direct play, but only if the client already declares a profile for that
-// format. All matching profiles are kept so negotiation can still pick among
-// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
-// unsupported.
+// ForceFormat narrows the client to transcoding to targetFormat, but only if the
+// client already declares a profile for it. All matching profiles are kept so
+// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt
+// from those profiles rather than dropped, since declaring a transcoding profile
+// for a format is proof the client can play it. Returns false when unsupported.
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
if targetFormat == "" {
return false
}
var matched []Profile
+ var directPlay []DirectPlayProfile
for i := range ci.TranscodingProfiles {
+ p := &ci.TranscodingProfiles[i]
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
// target_format) still matches a resolved "opus" profile.
- if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
- matched = append(matched, ci.TranscodingProfiles[i])
+ container, format := resolveTargetFormat(p)
+ if !matchesContainer(format, []string{targetFormat}) {
+ continue
}
+ matched = append(matched, *p)
+ directPlay = append(directPlay, DirectPlayProfile{
+ Containers: []string{container},
+ AudioCodecs: []string{format},
+ Protocols: []string{ProtocolHTTP},
+ MaxAudioChannels: p.MaxAudioChannels,
+ })
}
if len(matched) == 0 {
return false
}
ci.TranscodingProfiles = matched
- ci.DirectPlayProfiles = nil
+ ci.DirectPlayProfiles = directPlay
return true
}
diff --git a/core/stream/types_test.go b/core/stream/types_test.go
index eff408362..88ad904a7 100644
--- a/core/stream/types_test.go
+++ b/core/stream/types_test.go
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
})
Describe("ForceFormat", func() {
- It("restricts to the forced format and clears direct play when supported", func() {
+ It("restricts direct play to the forced format when supported", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
@@ -71,7 +71,35 @@ var _ = Describe("ClientInfo", func() {
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
- Expect(ci.DirectPlayProfiles).To(BeEmpty())
+ Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
+ Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP},
+ }))
+ })
+
+ It("keeps direct play for a source already in the forced format", func() {
+ ci := &ClientInfo{
+ DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
+ {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
+ },
+ }
+ ok := ci.ForceFormat("flac")
+ Expect(ok).To(BeTrue())
+ Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
+ Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP},
+ }))
+ })
+
+ It("carries the channel limit of the forced profile into direct play", func() {
+ ci := &ClientInfo{
+ TranscodingProfiles: []Profile{
+ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
+ },
+ }
+ Expect(ci.ForceFormat("flac")).To(BeTrue())
+ Expect(ci.DirectPlayProfiles).To(HaveLen(1))
+ Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2))
})
It("matches a container-only forced format (mp3)", func() {
diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go
index 9eb2af160..d64bce605 100644
--- a/server/subsonic/transcode.go
+++ b/server/subsonic/transcode.go
@@ -280,12 +280,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
return stream.IsAACCodec(p.Container)
})
+ player, hasPlayer := request.PlayerFrom(ctx)
+
// Honor the player's forced transcoding format, falling back to normal
// negotiation when the client can't play it (issue #5583).
+ maxBitRate := 0
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
- if !clientInfo.ForceFormat(trc.TargetFormat) {
+ if clientInfo.ForceFormat(trc.TargetFormat) {
+ // DirectPlayProfile carries no bitrate, so this ceiling is the only
+ // thing keeping an over-bitrate source out of direct play.
+ maxBitRate = trc.DefaultBitRate
+ } else {
clientName := clientInfo.Name
- if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
+ if hasPlayer && player.Client != "" {
clientName = player.Client
}
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
@@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
}
}
- // Apply the player's MaxBitRate as a ceiling on the client's declared
- // limits (issue #5583). Both fields are capped because the client sends
- // them independently here; capping only MaxAudioBitrate would let an
- // independent MaxTranscodingAudioBitrate slip through computeBitrate.
- if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) {
- log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision",
- "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
+ // The player's own MaxBitRate outranks the forced-format default (issue #5583).
+ if hasPlayer && player.MaxBitRate > 0 {
+ maxBitRate = player.MaxBitRate
+ }
+ if clientInfo.CapBitrate(maxBitRate) {
+ log.Debug(ctx, "Applied bitrate ceiling to transcode decision",
+ "maxBitRate", maxBitRate, "client", clientInfo.Name)
}
// Get media file
diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go
index 8d5cbb974..0f3c24832 100644
--- a/server/subsonic/transcode_test.go
+++ b/server/subsonic/transcode_test.go
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
mockTD.token = "token"
})
- It("forces a supported format and clears direct play", func() {
+ It("forces a supported format and narrows direct play to it", func() {
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
@@ -380,7 +380,11 @@ var _ = Describe("Transcode endpoints", func() {
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())
+ Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
+ Containers: []string{"ogg"},
+ AudioCodecs: []string{"opus"},
+ Protocols: []string{"http"},
+ }))
})
It("falls back to negotiation when the forced format is unsupported", func() {
@@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() {
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
})
+
+ withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
+ ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
+ ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
+ return r.WithContext(ctx)
+ }
+
+ It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
+ body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
+ r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
+
+ _, err := router.GetTranscodeDecision(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
+ Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
+ })
+
+ It("prefers the player maxBitRate over the transcoding default bitrate", func() {
+ body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
+ r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
+
+ _, err := router.GetTranscodeDecision(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
+ })
+
+ It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
+ body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
+ r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
+
+ _, err := router.GetTranscodeDecision(w, r)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
+ })
})
})
diff --git a/ui/src/consts.js b/ui/src/consts.js
index 472cd4940..46c76e194 100644
--- a/ui/src/consts.js
+++ b/ui/src/consts.js
@@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [
32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320,
].map((b) => ({ id: b, name: b.toString() }))
+
+// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express.
+export const TRANSCODING_BITRATE_CHOICES = [
+ { id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' },
+ ...BITRATE_CHOICES,
+]
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index de96d47c0..fd200fa60 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -200,6 +200,9 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
+ },
+ "choices": {
+ "noDefaultBitRate": "None"
}
},
"playlist": {
diff --git a/ui/src/transcoding/TranscodingCreate.jsx b/ui/src/transcoding/TranscodingCreate.jsx
index aaf122665..014a94e48 100644
--- a/ui/src/transcoding/TranscodingCreate.jsx
+++ b/ui/src/transcoding/TranscodingCreate.jsx
@@ -8,7 +8,7 @@ import {
useTranslate,
} from 'react-admin'
import { Title } from '../common'
-import { BITRATE_CHOICES } from '../consts'
+import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = () => {
const translate = useTranslate()
@@ -28,7 +28,7 @@ const TranscodingCreate = (props) => (
{
const translate = useTranslate()
@@ -28,7 +28,10 @@ const TranscodingEdit = (props) => {
-
+
diff --git a/ui/src/transcoding/TranscodingList.jsx b/ui/src/transcoding/TranscodingList.jsx
index bca8b49df..d1371c04a 100644
--- a/ui/src/transcoding/TranscodingList.jsx
+++ b/ui/src/transcoding/TranscodingList.jsx
@@ -1,7 +1,8 @@
import React from 'react'
-import { Datagrid, TextField } from 'react-admin'
+import { Datagrid, SelectField, TextField } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, List } from '../common'
+import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import config from '../config'
const TranscodingList = (props) => {
@@ -16,13 +17,22 @@ const TranscodingList = (props) => {
r.name}
secondaryText={(r) => `format: ${r.targetFormat}`}
- tertiaryText={(r) => r.defaultBitRate}
+ tertiaryText={(r) => (
+
+ )}
/>
) : (
-
+
)}
diff --git a/ui/src/transcoding/TranscodingShow.jsx b/ui/src/transcoding/TranscodingShow.jsx
index e132afee5..b7ec2f595 100644
--- a/ui/src/transcoding/TranscodingShow.jsx
+++ b/ui/src/transcoding/TranscodingShow.jsx
@@ -1,7 +1,8 @@
import React from 'react'
-import { Show, SimpleShowLayout, TextField } from 'react-admin'
+import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
+import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
return
@@ -16,7 +17,10 @@ const TranscodingShow = (props) => {
-
+