diff --git a/engine/media_streamer.go b/engine/media_streamer.go index b3d1381d9..9c0c998a0 100644 --- a/engine/media_streamer.go +++ b/engine/media_streamer.go @@ -14,12 +14,11 @@ import ( "github.com/deluan/navidrome/engine/transcoder" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" - "github.com/deluan/navidrome/utils" "github.com/djherbis/fscache" ) type MediaStreamer interface { - NewStream(ctx context.Context, id string, maxBitRate int, format string) (*Stream, error) + NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) } func NewMediaStreamer(ds model.DataStore, ffm transcoder.Transcoder, cache fscache.Cache) MediaStreamer { @@ -32,18 +31,23 @@ type mediaStreamer struct { cache fscache.Cache } -func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, reqFormat string) (*Stream, error) { +func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) { mf, err := ms.ds.MediaFile(ctx).Get(id) if err != nil { return nil, err } - bitRate, format := selectTranscodingOptions(mf, maxBitRate, reqFormat) + format, bitRate := selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate) + log.Trace(ctx, "Selected transcoding options", + "requestBitrate", reqBitRate, "requestFormat", reqFormat, + "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, + "selectedBitrate", bitRate, "selectedFormat", format, + ) s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} if format == "raw" { log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path, - "requestBitrate", maxBitRate, "requestFormat", reqFormat, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix) f, err := os.Open(mf.Path) if err != nil { @@ -66,7 +70,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in // If this is a brand new transcoding request, not in the cache, start transcoding if w != nil { log.Trace(ctx, "Cache miss. Starting new transcoding session", "id", mf.ID) - out, err := ms.ffm.Start(ctx, mf.Path, bitRate, format) + t, err := ms.ds.Transcoding(ctx).FindByFormat(format) + if err != nil { + log.Error(ctx, "Error loading transcoding command", "format", format, err) + return nil, os.ErrInvalid + } + out, err := ms.ffm.Start(ctx, t.Command, mf.Path, bitRate, format) if err != nil { log.Error(ctx, "Error starting transcoder", "id", mf.ID, err) return nil, os.ErrInvalid @@ -79,7 +88,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in size := getFinalCachedSize(r) if size > 0 { log.Debug(ctx, "Streaming cached file", "id", mf.ID, "path", mf.Path, - "requestBitrate", maxBitRate, "requestFormat", reqFormat, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "size", size) sr := io.NewSectionReader(r, 0, size) s.Reader = sr @@ -91,7 +100,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate in } log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path, - "requestBitrate", maxBitRate, "requestFormat", reqFormat, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix) // All other cases, just return a ReadCloser, without Seek capabilities s.Reader = r @@ -131,27 +140,46 @@ func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.form func (s *Stream) Name() string { return s.mf.Path } func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt } -func selectTranscodingOptions(mf *model.MediaFile, maxBitRate int, format string) (int, string) { - var bitRate int - - if format == "raw" || !conf.Server.EnableDownsampling { - return bitRate, "raw" +// TODO This function deserves some love (refactoring) +func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) { + format = "raw" + if reqFormat == "raw" { + return + } + trc, hasDefault := ctx.Value("transcoding").(model.Transcoding) + var cFormat string + var cBitRate int + if reqFormat != "" { + cFormat = reqFormat } else { - if maxBitRate == 0 { - bitRate = mf.BitRate - } else { - bitRate = utils.MinInt(mf.BitRate, maxBitRate) + if hasDefault { + cFormat = trc.TargetFormat + cBitRate = trc.DefaultBitRate + if p, ok := ctx.Value("player").(model.Player); ok { + cBitRate = p.MaxBitRate + } } - format = "mp3" //mf.Suffix } - if conf.Server.MaxBitRate != 0 { - bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate) + if reqBitRate > 0 { + cBitRate = reqBitRate } - - if bitRate == mf.BitRate { - return bitRate, "raw" + if cBitRate == 0 && cFormat == "" { + return } - return bitRate, format + t, err := ds.Transcoding(ctx).FindByFormat(cFormat) + if err == nil { + format = t.TargetFormat + if cBitRate != 0 { + bitRate = cBitRate + } else { + bitRate = t.DefaultBitRate + } + } + if format == mf.Suffix && bitRate > mf.BitRate { + format = "raw" + bitRate = 0 + } + return } func cacheKey(id string, bitRate int, format string) string { diff --git a/engine/media_streamer_test.go b/engine/media_streamer_test.go index d7a19d3b9..a7016ab9e 100644 --- a/engine/media_streamer_test.go +++ b/engine/media_streamer_test.go @@ -32,8 +32,8 @@ var _ = Describe("MediaStreamer", func() { BeforeEach(func() { conf.Server.EnableDownsampling = true - ds = &persistence.MockDataStore{} - ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128, "duration": 257.0}]`, 1) + ds = &persistence.MockDataStore{MockedTranscoding: &mockTranscodingRepository{}} + ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "suffix": "mp3", "bitRate": 128, "duration": 257.0}]`, 1) streamer = NewMediaStreamer(ds, ffmpeg, cache) }) @@ -43,33 +43,140 @@ var _ = Describe("MediaStreamer", func() { Context("NewStream", func() { It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, "123", 0, "raw") + s, err := streamer.NewStream(ctx, "123", "raw", 0) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if maxBitRate is 0", func() { - s, err := streamer.NewStream(ctx, "123", 0, "mp3") + s, err := streamer.NewStream(ctx, "123", "mp3", 0) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { - s, err := streamer.NewStream(ctx, "123", 320, "mp3") + s, err := streamer.NewStream(ctx, "123", "mp3", 320) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, "123", 64, "mp3") + s, err := streamer.NewStream(ctx, "123", "mp3", 64) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) It("returns a seekable stream if the file is complete in the cache", func() { Eventually(func() bool { return ffmpeg.closed }).Should(BeTrue()) - s, err := streamer.NewStream(ctx, "123", 64, "mp3") + s, err := streamer.NewStream(ctx, "123", "mp3", 64) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) }) + + Context("selectTranscodingOptions", func() { + mf := &model.MediaFile{} + Context("player is not configured", func() { + It("returns raw if raw is requested", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + Expect(format).To(Equal("raw")) + }) + It("returns raw if a transcoder does not exists", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0) + Expect(format).To(Equal("raw")) + }) + It("returns the requested format if a transcoder exists", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(160)) // Default Bit Rate + }) + It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() { + mf.Suffix = "mp3" + mf.BitRate = 112 + format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128) + Expect(format).To(Equal("raw")) + }) + It("returns the requested format if requested BitRate is lower than original", func() { + mf.Suffix = "mp3" + mf.BitRate = 320 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(192)) + }) + }) + + Context("player has format configured", func() { + BeforeEach(func() { + t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} + ctx = context.WithValue(ctx, "transcoding", t) + }) + It("returns raw if raw is requested", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + Expect(format).To(Equal("raw")) + }) + It("returns configured format/bitrate as default", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(96)) + }) + It("returns requested format", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(160)) // Default Bit Rate + }) + It("returns requested bitrate", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(80)) + }) + }) + Context("player has maxBitRate configured", func() { + BeforeEach(func() { + t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} + p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80} + ctx = context.WithValue(ctx, "transcoding", t) + ctx = context.WithValue(ctx, "player", p) + }) + It("returns raw if raw is requested", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + Expect(format).To(Equal("raw")) + }) + It("returns configured format/bitrate as default", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(80)) + }) + It("returns requested format", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(160)) // Default Bit Rate + }) + It("returns requested bitrate", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(80)) + }) + }) + }) }) type fakeFFmpeg struct { @@ -78,7 +185,7 @@ type fakeFFmpeg struct { closed bool } -func (ff *fakeFFmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { +func (ff *fakeFFmpeg) Start(ctx context.Context, cmd, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { ff.r = strings.NewReader(ff.Data) return ff, nil } diff --git a/engine/mock_transcoding_repo_test.go b/engine/mock_transcoding_repo_test.go new file mode 100644 index 000000000..f9523e805 --- /dev/null +++ b/engine/mock_transcoding_repo_test.go @@ -0,0 +1,22 @@ +package engine + +import "github.com/deluan/navidrome/model" + +type mockTranscodingRepository struct { + model.TranscodingRepository +} + +func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) { + return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil +} + +func (m *mockTranscodingRepository) FindByFormat(format string) (*model.Transcoding, error) { + switch format { + case "mp3": + return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil + case "oga": + return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil + default: + return nil, model.ErrNotFound + } +} diff --git a/engine/players_test.go b/engine/players_test.go index 1adfa866b..2a21b2f82 100644 --- a/engine/players_test.go +++ b/engine/players_test.go @@ -91,14 +91,6 @@ var _ = Describe("Players", func() { }) }) -type mockTranscodingRepository struct { - model.TranscodingRepository -} - -func (m *mockTranscodingRepository) Get(id string) (*model.Transcoding, error) { - return &model.Transcoding{ID: id, TargetFormat: "mp3"}, nil -} - type mockPlayerRepository struct { model.PlayerRepository lastSaved *model.Player diff --git a/engine/transcoder/ffmpeg.go b/engine/transcoder/ffmpeg.go index 00a83a7e3..4ab37ab3b 100644 --- a/engine/transcoder/ffmpeg.go +++ b/engine/transcoder/ffmpeg.go @@ -8,12 +8,11 @@ import ( "strconv" "strings" - "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/log" ) type Transcoder interface { - Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) + Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) } func New() Transcoder { @@ -22,8 +21,8 @@ func New() Transcoder { type ffmpeg struct{} -func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { - arg0, args := createTranscodeCommand(path, maxBitRate, format) +func (ff *ffmpeg) Start(ctx context.Context, command, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { + arg0, args := createTranscodeCommand(command, path, maxBitRate, format) log.Trace(ctx, "Executing ffmpeg command", "cmd", arg0, "args", args) cmd := exec.Command(arg0, args...) @@ -38,9 +37,7 @@ func (ff *ffmpeg) Start(ctx context.Context, path string, maxBitRate int, format return } -func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) { - cmd := conf.Server.DownsampleCommand - +func createTranscodeCommand(cmd, path string, maxBitRate int, format string) (string, []string) { split := strings.Split(cmd, " ") for i, s := range split { s = strings.Replace(s, "%s", path, -1) diff --git a/engine/transcoder/ffmpeg_test.go b/engine/transcoder/ffmpeg_test.go index b6231c8b2..c9f83526f 100644 --- a/engine/transcoder/ffmpeg_test.go +++ b/engine/transcoder/ffmpeg_test.go @@ -3,7 +3,6 @@ package transcoder import ( "testing" - "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/tests" . "github.com/onsi/ginkgo" @@ -18,11 +17,8 @@ func TestTranscoder(t *testing.T) { } var _ = Describe("createTranscodeCommand", func() { - BeforeEach(func() { - conf.Server.DownsampleCommand = "ffmpeg -i %s -b:a %bk mp3 -" - }) It("creates a valid command line", func() { - cmd, args := createTranscodeCommand("/music library/file.mp3", 123, "") + cmd, args := createTranscodeCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, "") Expect(cmd).To(Equal("ffmpeg")) Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) diff --git a/model/transcoding.go b/model/transcoding.go index 243badedc..52bb74ea3 100644 --- a/model/transcoding.go +++ b/model/transcoding.go @@ -13,4 +13,5 @@ type Transcodings []Transcoding type TranscodingRepository interface { Get(id string) (*Transcoding, error) Put(*Transcoding) error + FindByFormat(format string) (*Transcoding, error) } diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 06c2b4928..831ba4d30 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -28,6 +28,13 @@ func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) { return &res, err } +func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) { + sel := r.newSelect().Columns("*").Where(Eq{"target_format": format}) + var res model.Transcoding + err := r.queryOne(sel, &res) + return &res, err +} + func (r *transcodingRepository) Put(t *model.Transcoding) error { _, err := r.put(t.ID, t) return err diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index d151f22ad..6b991f0c2 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -27,7 +27,7 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp maxBitRate := utils.ParamInt(r, "maxBitRate", 0) format := utils.ParamString(r, "format") - stream, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format) + stream, err := c.streamer.NewStream(r.Context(), id, format, maxBitRate) if err != nil { return nil, err } @@ -62,7 +62,7 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re return nil, err } - stream, err := c.streamer.NewStream(r.Context(), id, 0, "raw") + stream, err := c.streamer.NewStream(r.Context(), id, "raw", 0) if err != nil { return nil, err }