Skip to content

Commit

Permalink
feat: experimental downsampling support
Browse files Browse the repository at this point in the history
  • Loading branch information
deluan committed Feb 4, 2020
1 parent 41fd586 commit 8372dee
Show file tree
Hide file tree
Showing 12 changed files with 307 additions and 157 deletions.
9 changes: 5 additions & 4 deletions conf/configuration.go
Expand Up @@ -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"`
Expand Down
2 changes: 2 additions & 0 deletions engine/engine_suite_test.go
Expand Up @@ -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")
Expand Down
205 changes: 205 additions & 0 deletions 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:]
}
68 changes: 68 additions & 0 deletions 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))
})
})
})
59 changes: 0 additions & 59 deletions engine/stream.go

This file was deleted.

30 changes: 0 additions & 30 deletions engine/stream_test.go

This file was deleted.

0 comments on commit 8372dee

Please sign in to comment.