diff --git a/conf/configuration.go b/conf/configuration.go index e50d86eed..8830218df 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -22,10 +22,11 @@ type nd struct { IgnoredArticles string `default:"The El La Los Las Le Les Os As O A"` IndexGroups string `default:"A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)"` - DisableDownsampling bool `default:"false"` - DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"` - ProbeCommand string `default:"ffmpeg %s -f ffmetadata"` - ScanInterval string `default:"1m"` + EnableDownsampling bool `default:"false"` + MaxBitRate int `default:"0"` + DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"` + ProbeCommand string `default:"ffmpeg %s -f ffmetadata"` + ScanInterval string `default:"1m"` // DevFlags. These are used to enable/disable debugging and incomplete features DevDisableBanner bool `default:"false"` diff --git a/engine/engine_suite_test.go b/engine/engine_suite_test.go index 72ec93112..bd2e50649 100644 --- a/engine/engine_suite_test.go +++ b/engine/engine_suite_test.go @@ -4,11 +4,13 @@ import ( "testing" "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/tests" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) func TestEngine(t *testing.T) { + tests.Init(t, false) log.SetLevel(log.LevelCritical) RegisterFailHandler(Fail) RunSpecs(t, "Engine Suite") diff --git a/engine/media_streamer.go b/engine/media_streamer.go new file mode 100644 index 000000000..4c04bba52 --- /dev/null +++ b/engine/media_streamer.go @@ -0,0 +1,205 @@ +package engine + +import ( + "context" + "io" + "io/ioutil" + "mime" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/deluan/navidrome/conf" + "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) +} + +func NewMediaStreamer(ds model.DataStore) MediaStreamer { + return &mediaStreamer{ds: ds} +} + +type mediaStream interface { + io.ReadSeeker + ContentType() string + Name() string + ModTime() time.Time + Close() error +} + +type mediaStreamer struct { + ds model.DataStore +} + +func (ms *mediaStreamer) NewStream(ctx context.Context, id string, maxBitRate int, format string) (mediaStream, error) { + mf, err := ms.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err + } + + var bitRate int + + if format == "raw" || !conf.Server.EnableDownsampling { + bitRate = mf.BitRate + format = mf.Suffix + } else { + if maxBitRate == 0 { + bitRate = mf.BitRate + } else { + bitRate = utils.MinInt(mf.BitRate, maxBitRate) + } + format = mf.Suffix + } + if conf.Server.MaxBitRate != 0 { + bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate) + } + + var stream mediaStream + + 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 + } + + log.Debug(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 +} + +type rawMediaStream struct { + file *os.File + ctx context.Context + mf *model.MediaFile +} + +func (m *rawMediaStream) Read(p []byte) (n int, err error) { + return m.file.Read(p) +} + +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) Close() error { + log.Trace(m.ctx, "Closing file", "id", m.mf.ID, "path", m.mf.Path) + return m.file.Close() +} + +type transcodedMediaStream struct { + ctx context.Context + mf *model.MediaFile + pipe io.ReadCloser + bitRate int + format string + skip int64 +} + +func (m *transcodedMediaStream) Read(p []byte) (n int, err error) { + 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) + if err != nil { + return 0, err + } + } + } + n, err = m.pipe.Read(p) + if err == io.EOF { + m.Close() + } + return +} + +// This Seek function assumes internal details of http.ServeContent's implementation +// A better approach would be to implement a http.FileSystem and use http.FileServer +func (m *transcodedMediaStream) Seek(offset int64, whence int) (int64, error) { + if whence == io.SeekEnd { + if offset == 0 { + size := (m.mf.Duration) * m.bitRate * 1000 + return int64(size / 8), nil + } + panic("seeking stream backwards not supported") + } + m.skip = offset + var err error + 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) Close() error { + log.Trace(m.ctx, "Closing stream", "id", m.mf.ID, "path", m.mf.Path) + err := m.pipe.Close() + m.pipe = nil + 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 + } + return f, cmd.Start() +} + +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/media_streamer_test.go b/engine/media_streamer_test.go new file mode 100644 index 000000000..92330e8c5 --- /dev/null +++ b/engine/media_streamer_test.go @@ -0,0 +1,68 @@ +package engine + +import ( + "os" + "time" + + "github.com/deluan/navidrome/conf" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" + "github.com/deluan/navidrome/persistence" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaStreamer", func() { + + var streamer MediaStreamer + var ds model.DataStore + ctx := log.NewContext(nil) + + BeforeEach(func() { + conf.Server.EnableDownsampling = true + ds = &persistence.MockDataStore{} + ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1) + streamer = NewMediaStreamer(ds) + }) + + Context("NewStream", func() { + It("returns a rawMediaStream if format is 'raw'", func() { + Expect(streamer.NewStream(ctx, "123", 0, "raw")).To(BeAssignableToTypeOf(&rawMediaStream{})) + }) + It("returns a rawMediaStream if maxBitRate is 0", func() { + Expect(streamer.NewStream(ctx, "123", 0, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{})) + }) + 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 transcodedMediaStream if maxBitRate is lower than file bitRate", func() { + s, err := streamer.NewStream(ctx, "123", 64, "mp3") + Expect(err).To(BeNil()) + Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{})) + Expect(s.(*transcodedMediaStream).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"} + file, err := os.Open("tests/fixtures/test.mp3") + if err != nil { + panic(err) + } + rawStream = &rawMediaStream{mf: mf, file: file, ctx: ctx} + }) + + It("returns the ContentType", func() { + Expect(rawStream.ContentType()).To(Equal("audio/mpeg")) + }) + + It("returns the ModTime", func() { + Expect(rawStream.ModTime()).To(Equal(modTime)) + }) + }) +}) diff --git a/engine/stream.go b/engine/stream.go deleted file mode 100644 index fa12c9a72..000000000 --- a/engine/stream.go +++ /dev/null @@ -1,59 +0,0 @@ -package engine - -import ( - "context" - "io" - "os" - "os/exec" - "strconv" - "strings" - - "github.com/deluan/navidrome/conf" - "github.com/deluan/navidrome/log" -) - -// TODO Encapsulate as a io.Reader -func Stream(ctx context.Context, path string, bitRate int, maxBitRate int, w io.Writer) error { - var f io.Reader - var err error - enabled := !conf.Server.DisableDownsampling - if enabled && maxBitRate > 0 && bitRate > maxBitRate { - f, err = downsample(ctx, path, maxBitRate) - } else { - f, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file", "path", path, err) - return err - } - if _, err = io.Copy(w, f); err != nil { - log.Error(ctx, "Error copying file", "path", path, err) - return err - } - return err -} - -func downsample(ctx context.Context, path string, maxBitRate int) (f io.Reader, err error) { - cmdLine, args := createDownsamplingCommand(path, maxBitRate) - - log.Debug(ctx, "Executing command", "cmdLine", cmdLine, "args", args) - cmd := exec.Command(cmdLine, args...) - cmd.Stderr = os.Stderr - if f, err = cmd.StdoutPipe(); err != nil { - return f, err - } - return f, cmd.Start() -} - -func createDownsamplingCommand(path string, maxBitRate int) (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/stream_test.go b/engine/stream_test.go deleted file mode 100644 index 953b8ecb4..000000000 --- a/engine/stream_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package engine - -import ( - "testing" - - . "github.com/deluan/navidrome/tests" - . "github.com/smartystreets/goconvey/convey" -) - -func TestDownsampling(t *testing.T) { - - Init(t, false) - - Convey("Subject: createDownsamplingCommand", t, func() { - - Convey("It should create a valid command line", func() { - cmd, args := createDownsamplingCommand("/music library/file.mp3", 128) - - So(cmd, ShouldEqual, "ffmpeg") - So(args[0], ShouldEqual, "-i") - So(args[1], ShouldEqual, "/music library/file.mp3") - So(args[2], ShouldEqual, "-b:a") - So(args[3], ShouldEqual, "128k") - So(args[4], ShouldEqual, "mp3") - So(args[5], ShouldEqual, "-") - }) - - }) - -} diff --git a/engine/wire_providers.go b/engine/wire_providers.go index caf85a03d..6b570b9d7 100644 --- a/engine/wire_providers.go +++ b/engine/wire_providers.go @@ -12,4 +12,5 @@ var Set = wire.NewSet( NewSearch, NewNowPlayingRepository, NewUsers, + NewMediaStreamer, ) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 421e9d5b8..1baa8b633 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -25,15 +25,17 @@ type Router struct { Scrobbler engine.Scrobbler Search engine.Search Users engine.Users + Streamer engine.MediaStreamer mux http.Handler } func New(browser engine.Browser, cover engine.Cover, listGenerator engine.ListGenerator, users engine.Users, - playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search) *Router { + playlists engine.Playlists, ratings engine.Ratings, scrobbler engine.Scrobbler, search engine.Search, + streamer engine.MediaStreamer) *Router { r := &Router{Browser: browser, Cover: cover, ListGenerator: listGenerator, Playlists: playlists, - Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users} + Ratings: ratings, Scrobbler: scrobbler, Search: search, Users: users, Streamer: streamer} r.mux = r.routes() return r } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index d1580f6e9..1682a9cde 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -2,92 +2,51 @@ package subsonic import ( "net/http" - "strconv" "github.com/deluan/navidrome/engine" - "github.com/deluan/navidrome/log" - "github.com/deluan/navidrome/model" "github.com/deluan/navidrome/server/subsonic/responses" - "github.com/deluan/navidrome/utils" ) type StreamController struct { - browser engine.Browser + streamer engine.MediaStreamer } -func NewStreamController(browser engine.Browser) *StreamController { - return &StreamController{browser: browser} +func NewStreamController(streamer engine.MediaStreamer) *StreamController { + return &StreamController{streamer: streamer} } -func (c *StreamController) getMediaFile(r *http.Request) (mf *engine.Entry, err error) { +func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + id, err := RequiredParamString(r, "id", "id parameter required") + if err != nil { + return nil, err + } + maxBitRate := ParamInt(r, "maxBitRate", 0) + format := ParamString(r, "format") + + ms, err := c.streamer.NewStream(r.Context(), id, maxBitRate, format) + 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) + return nil, nil +} + +func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { id, err := RequiredParamString(r, "id", "id parameter required") if err != nil { return nil, err } - mf, err = c.browser.GetSong(r.Context(), id) - switch { - case err == model.ErrNotFound: - log.Error(r, "Mediafile not found", "id", id) - return nil, NewError(responses.ErrorDataNotFound) - case err != nil: - log.Error(r, "Error reading mediafile from DB", "id", id, err) - return nil, NewError(responses.ErrorGeneric, "Internal error") - } - return -} - -// TODO Still getting the "Conn.Write wrote more than the declared Content-Length" error. -// Don't know if this causes any issues -func (c *StreamController) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { - mf, err := c.getMediaFile(r) + ms, err := c.streamer.NewStream(r.Context(), id, 0, "raw") if err != nil { return nil, err } - maxBitRate := ParamInt(r, "maxBitRate", 0) - maxBitRate = utils.MinInt(mf.BitRate, maxBitRate) - - log.Debug(r, "Streaming file", "id", mf.Id, "path", mf.AbsolutePath, "bitrate", mf.BitRate, "maxBitRate", maxBitRate) - - // TODO Send proper estimated content-length - //contentLength := mf.Size - //if maxBitRate > 0 { - // contentLength = strconv.Itoa((mf.Duration + 1) * maxBitRate * 1000 / 8) - //} - h := w.Header() - h.Set("Content-Length", strconv.Itoa(mf.Size)) - h.Set("Content-Type", "audio/mpeg") - h.Set("Expires", "0") - h.Set("Cache-Control", "must-revalidate") - h.Set("Pragma", "public") - - if r.Method == "HEAD" { - log.Debug(r, "Just a HEAD. Not streaming", "path", mf.AbsolutePath) - return nil, nil - } - - err = engine.Stream(r.Context(), mf.AbsolutePath, mf.BitRate, maxBitRate, w) - if err != nil { - log.Error(r, "Error streaming file", "id", mf.Id, err) - } - - log.Debug(r, "Finished streaming", "path", mf.AbsolutePath) - return nil, nil -} - -func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { - mf, err := c.getMediaFile(r) - if err != nil { - return nil, err - } - log.Debug(r, "Sending file", "path", mf.AbsolutePath) - - err = engine.Stream(r.Context(), mf.AbsolutePath, 0, 0, w) - if err != nil { - log.Error(r, "Error downloading file", "path", mf.AbsolutePath, err) - } - - log.Debug(r, "Finished sending", "path", mf.AbsolutePath) + // Override Content-Type detected by http.FileServer + w.Header().Set("Content-Type", ms.ContentType()) + http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms) return nil, nil } diff --git a/server/subsonic/wire_gen.go b/server/subsonic/wire_gen.go index 28d05923f..d67eb43de 100644 --- a/server/subsonic/wire_gen.go +++ b/server/subsonic/wire_gen.go @@ -59,8 +59,8 @@ func initMediaRetrievalController(router *Router) *MediaRetrievalController { } func initStreamController(router *Router) *StreamController { - browser := router.Browser - streamController := NewStreamController(browser) + mediaStreamer := router.Streamer + streamController := NewStreamController(mediaStreamer) return streamController } @@ -75,5 +75,5 @@ var allProviders = wire.NewSet( NewSearchingController, NewUsersController, NewMediaRetrievalController, - NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search"), + NewStreamController, wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"), ) diff --git a/server/subsonic/wire_injectors.go b/server/subsonic/wire_injectors.go index e39dde24e..7956b50df 100644 --- a/server/subsonic/wire_injectors.go +++ b/server/subsonic/wire_injectors.go @@ -16,7 +16,7 @@ var allProviders = wire.NewSet( NewUsersController, NewMediaRetrievalController, NewStreamController, - wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search"), + wire.FieldsOf(new(*Router), "Browser", "Cover", "ListGenerator", "Playlists", "Ratings", "Scrobbler", "Search", "Streamer"), ) func initSystemController(router *Router) *SystemController { diff --git a/wire_gen.go b/wire_gen.go index 53be3ff70..c6a7e877a 100644 --- a/wire_gen.go +++ b/wire_gen.go @@ -41,7 +41,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router { ratings := engine.NewRatings(dataStore) scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository) search := engine.NewSearch(dataStore) - router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search) + mediaStreamer := engine.NewMediaStreamer(dataStore) + router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer) return router }