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
 }