From a412989f7e39a143da28c51247a5b0af33f891a2 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@deluan.com> Date: Wed, 19 Feb 2020 14:53:35 -0500 Subject: [PATCH] refactor: more stable transcoder, based on http.FileSystem --- consts/consts.go | 2 + engine/browser_test.go | 2 +- engine/ffmpeg/ffmpeg.go | 52 ++++++ engine/ffmpeg/ffmpeg_test.go | 29 ++++ engine/media_streamer.go | 317 ++++++++++++++++------------------ engine/media_streamer_test.go | 90 +++++----- engine/wire_providers.go | 6 +- server/subsonic/stream.go | 20 +-- wire_gen.go | 4 +- 9 files changed, 294 insertions(+), 228 deletions(-) create mode 100644 engine/ffmpeg/ffmpeg.go create mode 100644 engine/ffmpeg/ffmpeg_test.go diff --git a/consts/consts.go b/consts/consts.go index 90ceb657f..c9147d59c 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -14,6 +14,8 @@ const ( UIAssetsLocalPath = "ui/build" + CacheDir = "cache" + DevInitialUserName = "admin" DevInitialName = "Dev Admin" ) diff --git a/engine/browser_test.go b/engine/browser_test.go index 92a7c711c..0d1442e22 100644 --- a/engine/browser_test.go +++ b/engine/browser_test.go @@ -14,7 +14,7 @@ var _ = Describe("Browser", func() { var repo *mockGenreRepository var b Browser - BeforeSuite(func() { + BeforeEach(func() { repo = &mockGenreRepository{data: model.Genres{ {Name: "Rock", SongCount: 1000, AlbumCount: 100}, {Name: "", SongCount: 13, AlbumCount: 13}, diff --git a/engine/ffmpeg/ffmpeg.go b/engine/ffmpeg/ffmpeg.go new file mode 100644 index 000000000..dc5143b50 --- /dev/null +++ b/engine/ffmpeg/ffmpeg.go @@ -0,0 +1,52 @@ +package ffmpeg + +import ( + "context" + "io" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/deluan/navidrome/conf" + "github.com/deluan/navidrome/log" +) + +type FFmpeg interface { + StartTranscoding(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) +} + +func New() FFmpeg { + return &ffmpeg{} +} + +type ffmpeg struct{} + +func (ff *ffmpeg) StartTranscoding(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { + cmdLine, args := createTranscodeCommand(path, maxBitRate, format) + + log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args) + cmd := exec.Command(cmdLine, args...) + cmd.Stderr = os.Stderr + if f, err = cmd.StdoutPipe(); err != nil { + return f, err + } + if err = cmd.Start(); err != nil { + return f, err + } + go cmd.Wait() // prevent zombies + return f, err +} + +func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) { + cmd := conf.Server.DownsampleCommand + + split := strings.Split(cmd, " ") + for i, s := range split { + s = strings.Replace(s, "%s", path, -1) + s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1) + split[i] = s + } + + return split[0], split[1:] +} diff --git a/engine/ffmpeg/ffmpeg_test.go b/engine/ffmpeg/ffmpeg_test.go new file mode 100644 index 000000000..7f841360d --- /dev/null +++ b/engine/ffmpeg/ffmpeg_test.go @@ -0,0 +1,29 @@ +package ffmpeg + +import ( + "testing" + + "github.com/deluan/navidrome/conf" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestFFmpeg(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "FFmpeg Suite") +} + +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, "") + Expect(cmd).To(Equal("ffmpeg")) + Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) + }) +}) diff --git a/engine/media_streamer.go b/engine/media_streamer.go index 201783204..9731bbed6 100644 --- a/engine/media_streamer.go +++ b/engine/media_streamer.go @@ -2,232 +2,207 @@ package engine import ( "context" + "fmt" "io" - "io/ioutil" - "mime" + "net/http" "os" - "os/exec" - "strconv" + "path/filepath" "strings" "time" "github.com/deluan/navidrome/conf" + "github.com/deluan/navidrome/consts" + "github.com/deluan/navidrome/engine/ffmpeg" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" "github.com/deluan/navidrome/utils" ) type MediaStreamer interface { - NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) + NewFileSystem(ctx context.Context, maxBitRate int, format string) (http.FileSystem, error) } -func NewMediaStreamer(ds model.DataStore) MediaStreamer { - return &mediaStreamer{ds: ds} -} - -type mediaStream interface { - io.ReadSeeker - ContentType() string - Name() string - ModTime() time.Time - Close() error - Duration() int +func NewMediaStreamer(ds model.DataStore, ffm ffmpeg.FFmpeg) MediaStreamer { + return &mediaStreamer{ds: ds, ffm: ffm} } type mediaStreamer struct { - ds model.DataStore + ds model.DataStore + ffm ffmpeg.FFmpeg } -func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) { - mf, err := ms.ds.MediaFile(ctx).Get(id) +func (ms *mediaStreamer) NewFileSystem(ctx context.Context, maxBitRate int, format string) (http.FileSystem, error) { + cacheFolder := filepath.Join(conf.Server.DataFolder, consts.CacheDir) + err := os.MkdirAll(cacheFolder, 0755) if err != nil { + log.Error("Could not create cache folder", "folder", cacheFolder, err) return nil, err } + return &mediaFileSystem{ctx: ctx, ds: ms.ds, ffm: ms.ffm, maxBitRate: maxBitRate, format: format, cacheFolder: cacheFolder}, nil +} +type mediaFileSystem struct { + ctx context.Context + ds model.DataStore + maxBitRate int + format string + cacheFolder string + ffm ffmpeg.FFmpeg +} + +func (fs *mediaFileSystem) selectTranscodingOptions(mf *model.MediaFile) (string, int) { var bitRate int + var format string - if format == "raw" || !conf.Server.EnableDownsampling { - bitRate = mf.BitRate - format = mf.Suffix + if fs.format == "raw" || !conf.Server.EnableDownsampling { + return "raw", bitRate } else { - if maxBitRate == 0 { + if fs.maxBitRate == 0 { bitRate = mf.BitRate } else { - bitRate = utils.MinInt(mf.BitRate, maxBitRate) + bitRate = utils.MinInt(mf.BitRate, fs.maxBitRate) } - format = mf.Suffix + format = "mp3" //mf.Suffix } if conf.Server.MaxBitRate != 0 { bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate) } - var stream mediaStream + if bitRate == mf.BitRate { + return "raw", bitRate + } + return format, bitRate +} - if bitRate == mf.BitRate && mime.TypeByExtension("."+format) == mf.ContentType() { - log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path, - "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix) - - f, err := os.Open(mf.Path) - if err != nil { - return nil, err - } - stream = &rawMediaStream{ctx: ctx, mf: mf, file: f} - return stream, nil +func (fs *mediaFileSystem) Open(name string) (http.File, error) { + id := strings.Trim(name, "/") + mf, err := fs.ds.MediaFile(fs.ctx).Get(id) + if err == model.ErrNotFound { + return nil, os.ErrNotExist + } + if err != nil { + log.Error("Error opening mediaFile", "id", id, err) + return nil, os.ErrInvalid } - log.Debug(ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path, + format, bitRate := fs.selectTranscodingOptions(mf) + if format == "raw" { + log.Debug(fs.ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path, + "requestBitrate", bitRate, "requestFormat", format, + "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix) + return os.Open(mf.Path) + } + + cachedFile := fs.cacheFilePath(mf, bitRate, format) + if _, err := os.Stat(cachedFile); !os.IsNotExist(err) { + log.Debug(fs.ctx, "Streaming cached transcoded", "id", mf.ID, "path", mf.Path, + "requestBitrate", bitRate, "requestFormat", format, + "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix) + return os.Open(cachedFile) + } + + log.Debug(fs.ctx, "Streaming transcoded file", "id", mf.ID, "path", mf.Path, "requestBitrate", bitRate, "requestFormat", format, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix) - f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format} - return f, err + return fs.transcodeFile(mf, bitRate, format, cachedFile) } -type rawMediaStream struct { - file *os.File - ctx context.Context - mf *model.MediaFile +func (fs *mediaFileSystem) cacheFilePath(mf *model.MediaFile, bitRate int, format string) string { + subDir := strings.ToLower(mf.ID[:2]) + subDir = filepath.Join(fs.cacheFolder, subDir) + if err := os.Mkdir(subDir, 0755); err != nil { + log.Error("Error creating cache folder. Bad things will happen", "folder", subDir, err) + } + + return filepath.Join(subDir, fmt.Sprintf("%s.%d.%s", mf.ID, bitRate, format)) } -func (m *rawMediaStream) Read(p []byte) (n int, err error) { - return m.file.Read(p) +func (fs *mediaFileSystem) transcodeFile(mf *model.MediaFile, bitRate int, format, cacheFile string) (*transcodingFile, error) { + out, err := fs.ffm.StartTranscoding(fs.ctx, mf.Path, bitRate, format) + if err != nil { + log.Error("Error starting transcoder", "id", mf.ID, err) + return nil, os.ErrInvalid + } + buf, err := newStreamBuffer(cacheFile) + if err != nil { + log.Error("Error creating stream buffer", "id", mf.ID, err) + return nil, os.ErrInvalid + } + r, err := buf.NewReader() + if err != nil { + log.Error("Error opening stream reader", "id", mf.ID, err) + return nil, os.ErrInvalid + } + go func() { + io.Copy(buf, out) + out.Close() + buf.Sync() + buf.Close() + }() + s := &transcodingFile{ + ctx: fs.ctx, + mf: mf, + bitRate: bitRate, + } + s.File = r + return s, nil } -func (m *rawMediaStream) Seek(offset int64, whence int) (int64, error) { - return m.file.Seek(offset, whence) -} - -func (m *rawMediaStream) ContentType() string { - return m.mf.ContentType() -} - -func (m *rawMediaStream) Name() string { - return m.mf.Path -} - -func (m *rawMediaStream) ModTime() time.Time { - return m.mf.UpdatedAt -} - -func (m *rawMediaStream) Duration() int { - return m.mf.Duration -} - -func (m *rawMediaStream) Close() error { - log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path) - return m.file.Close() -} - -type transcodedMediaStream struct { +type transcodingFile struct { ctx context.Context mf *model.MediaFile - pipe io.ReadCloser bitRate int - format string - skip int64 - pos int64 + http.File } -func (m *transcodedMediaStream) Read(p []byte) (n int, err error) { - // Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek) - if m.pipe == nil { - m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format) - if err != nil { - return 0, err - } - if m.skip > 0 { - _, err := io.CopyN(ioutil.Discard, m.pipe, m.skip) - m.pos = m.skip - if err != nil { - return 0, err - } +func (h *transcodingFile) Stat() (os.FileInfo, error) { + return &streamHandlerFileInfo{mf: h.mf, bitRate: h.bitRate}, nil +} + +// Don't return EOF, just wait for more data. When the request ends, this "File" will be closed, and then +// the Read will be interrupted +func (h *transcodingFile) Read(b []byte) (int, error) { + for { + n, err := h.File.Read(b) + if n > 0 { + return n, nil + } else if err != io.EOF { + return n, err } + time.Sleep(100 * time.Millisecond) } - n, err = m.pipe.Read(p) - m.pos += int64(n) - if err == io.EOF { - m.Close() +} + +type streamHandlerFileInfo struct { + mf *model.MediaFile + bitRate int +} + +func (f *streamHandlerFileInfo) Name() string { return f.mf.Title } +func (f *streamHandlerFileInfo) Size() int64 { return int64((f.mf.Duration)*f.bitRate*1000) / 8 } +func (f *streamHandlerFileInfo) Mode() os.FileMode { return os.FileMode(0777) } +func (f *streamHandlerFileInfo) ModTime() time.Time { return f.mf.UpdatedAt } +func (f *streamHandlerFileInfo) IsDir() bool { return false } +func (f *streamHandlerFileInfo) Sys() interface{} { return nil } + +// From: https://stackoverflow.com/a/44322300 +type streamBuffer struct { + *os.File +} + +func (mb *streamBuffer) NewReader() (http.File, error) { + f, err := os.Open(mb.Name()) + if err != nil { + return nil, err } - return + return f, nil } -// This is an attempt to make a pipe seekable. It is very wasteful, restarting the stream every time -// a Seek happens. This is ok-ish for audio, but would kill the server for video. -func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) { - size := int64((m.mf.Duration)*m.bitRate*1000) / 8 - log.Trace(m.ctx, "Seeking transcoded stream", "path", m.mf.Path, "offset", offset, "whence", whence, "size", size) - - switch whence { - case io.SeekEnd: - m.skip = size - offset - offset = size - case io.SeekStart: - m.skip = offset - case io.SeekCurrent: - io.CopyN(ioutil.Discard, m.pipe, offset) - m.pos += offset - offset = m.pos +func newStreamBuffer(name string) (*streamBuffer, error) { + f, err := os.Create(name) + if err != nil { + return nil, err } - - // If need to Seek to a previous position, close the pipe (will be restarted on next Read) - var err error - if whence != io.SeekCurrent { - if m.pipe != nil { - err = m.Close() - } - } - return offset, err -} - -func (m *transcodedMediaStream) ContentType() string { - return mime.TypeByExtension(".mp3") -} - -func (m *transcodedMediaStream) Name() string { - return m.mf.Path -} - -func (m *transcodedMediaStream) ModTime() time.Time { - return m.mf.UpdatedAt -} - -func (m *transcodedMediaStream) Duration() int { - return m.mf.Duration -} - -func (m *transcodedMediaStream) Close() error { - log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path) - err := m.pipe.Close() - m.pipe = nil - m.pos = 0 - return err -} - -func newTranscode(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { - cmdLine, args := createTranscodeCommand(path, maxBitRate, format) - - log.Trace(ctx, "Executing ffmpeg command", "arg0", cmdLine, "args", args) - cmd := exec.Command(cmdLine, args...) - cmd.Stderr = os.Stderr - if f, err = cmd.StdoutPipe(); err != nil { - return f, err - } - if err = cmd.Start(); err != nil { - return f, err - } - go cmd.Wait() // prevent zombies - return f, err -} - -func createTranscodeCommand(path string, maxBitRate int, format string) (string, []string) { - cmd := conf.Server.DownsampleCommand - - split := strings.Split(cmd, " ") - for i, s := range split { - s = strings.Replace(s, "%s", path, -1) - s = strings.Replace(s, "%b", strconv.Itoa(maxBitRate), -1) - split[i] = s - } - - return split[0], split[1:] + return &streamBuffer{File: f}, nil } diff --git a/engine/media_streamer_test.go b/engine/media_streamer_test.go index 329c69408..c75a16d22 100644 --- a/engine/media_streamer_test.go +++ b/engine/media_streamer_test.go @@ -1,7 +1,12 @@ package engine import ( - "time" + "context" + "io" + "io/ioutil" + "net/http" + "os" + "strings" "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/log" @@ -15,61 +20,58 @@ var _ = Describe("MediaStreamer", func() { var streamer MediaStreamer var ds model.DataStore + var tempDir string ctx := log.NewContext(nil) - BeforeEach(func() { + BeforeSuite(func() { conf.Server.EnableDownsampling = true + tempDir, err := ioutil.TempDir("", "stream_tests") + if err != nil { + panic(err) + } + conf.Server.DataFolder = tempDir + }) + + BeforeEach(func() { ds = &persistence.MockDataStore{} ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1) - streamer = NewMediaStreamer(ds) + streamer = NewMediaStreamer(ds, &fakeFFmpeg{}) }) - Context("NewStream", func() { - It("returns a rawMediaStream if format is 'raw'", func() { - Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{})) + AfterSuite(func() { + os.RemoveAll(tempDir) + }) + + getFile := func(id string, maxBitRate int, format string) (http.File, error) { + fs, _ := streamer.NewFileSystem(ctx, maxBitRate, format) + return fs.Open(id) + } + + Context("NewFileSystem", func() { + It("returns a File if format is 'raw'", func() { + Expect(getFile("123", 0, "raw")).To(BeAssignableToTypeOf(&os.File{})) }) - It("returns a rawMediaStream if maxBitRate is 0", func() { - Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{})) + It("returns a File if maxBitRate is 0", func() { + Expect(getFile("123", 0, "mp3")).To(BeAssignableToTypeOf(&os.File{})) }) - It("returns a rawMediaStream if maxBitRate is higher than file bitRate", func() { - Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{})) + It("returns a File if maxBitRate is higher than file bitRate", func() { + Expect(getFile("123", 256, "mp3")).To(BeAssignableToTypeOf(&os.File{})) }) - It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() { - s, err := streamer.NewStream(ctx, "123", 64, "mp3") + It("returns a transcodingFile if maxBitRate is lower than file bitRate", func() { + s, err := getFile("123", 64, "mp3") Expect(err).To(BeNil()) - Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{})) - Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64)) + Expect(s).To(BeAssignableToTypeOf(&transcodingFile{})) + Expect(s.(*transcodingFile).bitRate).To(Equal(64)) }) - }) - - Context("rawMediaStream", func() { - var rawStream mediaStream - var modTime time.Time - - BeforeEach(func() { - modTime = time.Now() - mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"} - rawStream = &rawMediaStream{mf: mf, ctx: ctx} + It("returns a File if the transcoding is cached", func() { + Expect(getFile("123", 64, "mp3")).To(BeAssignableToTypeOf(&os.File{})) }) - - It("returns the ContentType", func() { - Expect(rawStream.ContentType()).To(Equal("audio/mpeg")) - }) - - It("returns the ModTime", func() { - Expect(rawStream.ModTime()).To(Equal(modTime)) - }) - }) - - Context("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, "") - Expect(cmd).To(Equal("ffmpeg")) - Expect(args).To(Equal([]string{"-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) - }) - }) }) + +type fakeFFmpeg struct { +} + +func (ff *fakeFFmpeg) StartTranscoding(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) { + return ioutil.NopCloser(strings.NewReader("fake data")), nil +} diff --git a/engine/wire_providers.go b/engine/wire_providers.go index 6b570b9d7..38186ec05 100644 --- a/engine/wire_providers.go +++ b/engine/wire_providers.go @@ -1,6 +1,9 @@ package engine -import "github.com/google/wire" +import ( + "github.com/deluan/navidrome/engine/ffmpeg" + "github.com/google/wire" +) var Set = wire.NewSet( NewBrowser, @@ -13,4 +16,5 @@ var Set = wire.NewSet( NewNowPlayingRepository, NewUsers, NewMediaStreamer, + ffmpeg.New, ) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 5f93e50cb..9a537fd42 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -2,7 +2,6 @@ package subsonic import ( "net/http" - "strconv" "github.com/deluan/navidrome/engine" "github.com/deluan/navidrome/server/subsonic/responses" @@ -25,15 +24,15 @@ func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*resp maxBitRate := utils.ParamInt(r, "maxBitRate", 0) format := utils.ParamString(r, "format") - ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format) + fs, err := c.streamer.NewFileSystem(r.Context(), maxBitRate, format) if err != nil { return nil, err } - // Override Content-Type detected by http.FileServer - w.Header().Set("Content-Type", ms.ContentType()) - w.Header().Set("X-Content-Duration", strconv.Itoa(ms.Duration())) - http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms) + // To be able to use a http.FileSystem, we need to change the URL structure + r.URL.Path = id + + http.FileServer(fs).ServeHTTP(w, r) return nil, nil } @@ -43,13 +42,14 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re return nil, err } - ms, err := c.streamer.NewStream(r.Context(), id, 0, "raw") + fs, err := c.streamer.NewFileSystem(r.Context(), 0, "raw") if err != nil { return nil, err } - // Override Content-Type detected by http.FileServer - w.Header().Set("Content-Type", ms.ContentType()) - http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms) + // To be able to use a http.FileSystem, we need to change the URL structure + r.URL.Path = id + + http.FileServer(fs).ServeHTTP(w, r) return nil, nil } diff --git a/wire_gen.go b/wire_gen.go index c6a7e877a..c538885cd 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -7,6 +7,7 @@ package main import ( "github.com/deluan/navidrome/engine" + "github.com/deluan/navidrome/engine/ffmpeg" "github.com/deluan/navidrome/persistence" "github.com/deluan/navidrome/scanner" "github.com/deluan/navidrome/server" @@ -41,7 +42,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router { ratings := engine.NewRatings(dataStore) scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository) search := engine.NewSearch(dataStore) - mediaStreamer := engine.NewMediaStreamer(dataStore) + fFmpeg := ffmpeg.New() + mediaStreamer := engine.NewMediaStreamer(dataStore, fFmpeg) router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer) return router }