refactor: more stable transcoder, based on http.FileSystem
This commit is contained in:
@@ -14,6 +14,8 @@ const (
|
|||||||
|
|
||||||
UIAssetsLocalPath = "ui/build"
|
UIAssetsLocalPath = "ui/build"
|
||||||
|
|
||||||
|
CacheDir = "cache"
|
||||||
|
|
||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ var _ = Describe("Browser", func() {
|
|||||||
var repo *mockGenreRepository
|
var repo *mockGenreRepository
|
||||||
var b Browser
|
var b Browser
|
||||||
|
|
||||||
BeforeSuite(func() {
|
BeforeEach(func() {
|
||||||
repo = &mockGenreRepository{data: model.Genres{
|
repo = &mockGenreRepository{data: model.Genres{
|
||||||
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
{Name: "Rock", SongCount: 1000, AlbumCount: 100},
|
||||||
{Name: "", SongCount: 13, AlbumCount: 13},
|
{Name: "", SongCount: 13, AlbumCount: 13},
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
}
|
||||||
@@ -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", "-"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
+156
-181
@@ -2,232 +2,207 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"net/http"
|
||||||
"mime"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/deluan/navidrome/conf"
|
"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/log"
|
||||||
"github.com/deluan/navidrome/model"
|
"github.com/deluan/navidrome/model"
|
||||||
"github.com/deluan/navidrome/utils"
|
"github.com/deluan/navidrome/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaStreamer interface {
|
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 {
|
func NewMediaStreamer(ds model.DataStore, ffm ffmpeg.FFmpeg) MediaStreamer {
|
||||||
return &mediaStreamer{ds: ds}
|
return &mediaStreamer{ds: ds, ffm: ffm}
|
||||||
}
|
|
||||||
|
|
||||||
type mediaStream interface {
|
|
||||||
io.ReadSeeker
|
|
||||||
ContentType() string
|
|
||||||
Name() string
|
|
||||||
ModTime() time.Time
|
|
||||||
Close() error
|
|
||||||
Duration() int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type mediaStreamer struct {
|
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) {
|
func (ms *mediaStreamer) NewFileSystem(ctx context.Context, maxBitRate int, format string) (http.FileSystem, error) {
|
||||||
mf, err := ms.ds.MediaFile(ctx).Get(id)
|
cacheFolder := filepath.Join(conf.Server.DataFolder, consts.CacheDir)
|
||||||
|
err := os.MkdirAll(cacheFolder, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Error("Could not create cache folder", "folder", cacheFolder, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return &mediaFileSystem{ctx: ctx, ds: ms.ds, ffm: ms.ffm, maxBitRate: maxBitRate, format: format, cacheFolder: cacheFolder}, nil
|
||||||
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
|
|
||||||
|
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 fs.format == "raw" || !conf.Server.EnableDownsampling {
|
||||||
|
return "raw", bitRate
|
||||||
|
} else {
|
||||||
|
if fs.maxBitRate == 0 {
|
||||||
|
bitRate = mf.BitRate
|
||||||
|
} else {
|
||||||
|
bitRate = utils.MinInt(mf.BitRate, fs.maxBitRate)
|
||||||
|
}
|
||||||
|
format = "mp3" //mf.Suffix
|
||||||
}
|
}
|
||||||
if conf.Server.MaxBitRate != 0 {
|
if conf.Server.MaxBitRate != 0 {
|
||||||
bitRate = utils.MinInt(bitRate, conf.Server.MaxBitRate)
|
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() {
|
func (fs *mediaFileSystem) Open(name string) (http.File, error) {
|
||||||
log.Debug(ctx, "Streaming raw file", "id", mf.ID, "path", mf.Path,
|
id := strings.Trim(name, "/")
|
||||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
mf, err := fs.ds.MediaFile(fs.ctx).Get(id)
|
||||||
|
if err == model.ErrNotFound {
|
||||||
f, err := os.Open(mf.Path)
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
log.Error("Error opening mediaFile", "id", id, err)
|
||||||
}
|
return nil, os.ErrInvalid
|
||||||
stream = &rawMediaStream{ctx: ctx, mf: mf, file: f}
|
|
||||||
return stream, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
"requestBitrate", bitRate, "requestFormat", format,
|
||||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix)
|
||||||
|
|
||||||
f := &transcodedMediaStream{ctx: ctx, mf: mf, bitRate: bitRate, format: format}
|
return fs.transcodeFile(mf, bitRate, format, cachedFile)
|
||||||
return f, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type rawMediaStream struct {
|
func (fs *mediaFileSystem) cacheFilePath(mf *model.MediaFile, bitRate int, format string) string {
|
||||||
file *os.File
|
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 (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
|
||||||
|
}
|
||||||
|
|
||||||
|
type transcodingFile struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
mf *model.MediaFile
|
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) 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 {
|
|
||||||
ctx context.Context
|
|
||||||
mf *model.MediaFile
|
|
||||||
pipe io.ReadCloser
|
|
||||||
bitRate int
|
bitRate int
|
||||||
format string
|
http.File
|
||||||
skip int64
|
|
||||||
pos int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *transcodedMediaStream) Read(p []byte) (n int, err error) {
|
func (h *transcodingFile) Stat() (os.FileInfo, error) {
|
||||||
// Open the pipe and optionally skip a initial chunk of the stream (to simulate a Seek)
|
return &streamHandlerFileInfo{mf: h.mf, bitRate: h.bitRate}, nil
|
||||||
if m.pipe == nil {
|
}
|
||||||
m.pipe, err = newTranscode(m.ctx, m.mf.Path, m.bitRate, m.format)
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if m.skip > 0 {
|
return f, nil
|
||||||
_, err := io.CopyN(ioutil.Discard, m.pipe, m.skip)
|
}
|
||||||
m.pos = m.skip
|
|
||||||
|
func newStreamBuffer(name string) (*streamBuffer, error) {
|
||||||
|
f, err := os.Create(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
return &streamBuffer{File: f}, nil
|
||||||
}
|
|
||||||
n, err = m.pipe.Read(p)
|
|
||||||
m.pos += int64(n)
|
|
||||||
if err == io.EOF {
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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:]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"context"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/deluan/navidrome/conf"
|
"github.com/deluan/navidrome/conf"
|
||||||
"github.com/deluan/navidrome/log"
|
"github.com/deluan/navidrome/log"
|
||||||
@@ -15,61 +20,58 @@ var _ = Describe("MediaStreamer", func() {
|
|||||||
|
|
||||||
var streamer MediaStreamer
|
var streamer MediaStreamer
|
||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
|
var tempDir string
|
||||||
ctx := log.NewContext(nil)
|
ctx := log.NewContext(nil)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeSuite(func() {
|
||||||
conf.Server.EnableDownsampling = true
|
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 = &persistence.MockDataStore{}
|
||||||
ds.MediaFile(ctx).(*persistence.MockMediaFile).SetData(`[{"id": "123", "path": "tests/fixtures/test.mp3", "bitRate": 128}]`, 1)
|
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() {
|
AfterSuite(func() {
|
||||||
It("returns a rawMediaStream if format is 'raw'", func() {
|
os.RemoveAll(tempDir)
|
||||||
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{}))
|
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 higher than file bitRate", func() {
|
It("returns a File if maxBitRate is 0", func() {
|
||||||
Expect(streamer.NewStream(ctx, "123", 256, "mp3")).To(BeAssignableToTypeOf(&rawMediaStream{}))
|
Expect(getFile("123", 0, "mp3")).To(BeAssignableToTypeOf(&os.File{}))
|
||||||
})
|
})
|
||||||
It("returns a transcodedMediaStream if maxBitRate is lower than file bitRate", func() {
|
It("returns a File if maxBitRate is higher than file bitRate", func() {
|
||||||
s, err := streamer.NewStream(ctx, "123", 64, "mp3")
|
Expect(getFile("123", 256, "mp3")).To(BeAssignableToTypeOf(&os.File{}))
|
||||||
|
})
|
||||||
|
It("returns a transcodingFile if maxBitRate is lower than file bitRate", func() {
|
||||||
|
s, err := getFile("123", 64, "mp3")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(s).To(BeAssignableToTypeOf(&transcodedMediaStream{}))
|
Expect(s).To(BeAssignableToTypeOf(&transcodingFile{}))
|
||||||
Expect(s.(*transcodedMediaStream).bitRate).To(Equal(64))
|
Expect(s.(*transcodingFile).bitRate).To(Equal(64))
|
||||||
|
})
|
||||||
|
It("returns a File if the transcoding is cached", func() {
|
||||||
|
Expect(getFile("123", 64, "mp3")).To(BeAssignableToTypeOf(&os.File{}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("rawMediaStream", func() {
|
type fakeFFmpeg struct {
|
||||||
var rawStream mediaStream
|
}
|
||||||
var modTime time.Time
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
func (ff *fakeFFmpeg) StartTranscoding(ctx context.Context, path string, maxBitRate int, format string) (f io.ReadCloser, err error) {
|
||||||
modTime = time.Now()
|
return ioutil.NopCloser(strings.NewReader("fake data")), nil
|
||||||
mf := &model.MediaFile{ID: "123", Path: "test.mp3", UpdatedAt: modTime, Suffix: "mp3"}
|
}
|
||||||
rawStream = &rawMediaStream{mf: mf, 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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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", "-"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
import "github.com/google/wire"
|
import (
|
||||||
|
"github.com/deluan/navidrome/engine/ffmpeg"
|
||||||
|
"github.com/google/wire"
|
||||||
|
)
|
||||||
|
|
||||||
var Set = wire.NewSet(
|
var Set = wire.NewSet(
|
||||||
NewBrowser,
|
NewBrowser,
|
||||||
@@ -13,4 +16,5 @@ var Set = wire.NewSet(
|
|||||||
NewNowPlayingRepository,
|
NewNowPlayingRepository,
|
||||||
NewUsers,
|
NewUsers,
|
||||||
NewMediaStreamer,
|
NewMediaStreamer,
|
||||||
|
ffmpeg.New,
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-10
@@ -2,7 +2,6 @@ package subsonic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/deluan/navidrome/engine"
|
"github.com/deluan/navidrome/engine"
|
||||||
"github.com/deluan/navidrome/server/subsonic/responses"
|
"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)
|
maxBitRate := utils.ParamInt(r, "maxBitRate", 0)
|
||||||
format := utils.ParamString(r, "format")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override Content-Type detected by http.FileServer
|
// To be able to use a http.FileSystem, we need to change the URL structure
|
||||||
w.Header().Set("Content-Type", ms.ContentType())
|
r.URL.Path = id
|
||||||
w.Header().Set("X-Content-Duration", strconv.Itoa(ms.Duration()))
|
|
||||||
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
|
http.FileServer(fs).ServeHTTP(w, r)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +42,14 @@ func (c *StreamController) Download(w http.ResponseWriter, r *http.Request) (*re
|
|||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override Content-Type detected by http.FileServer
|
// To be able to use a http.FileSystem, we need to change the URL structure
|
||||||
w.Header().Set("Content-Type", ms.ContentType())
|
r.URL.Path = id
|
||||||
http.ServeContent(w, r, ms.Name(), ms.ModTime(), ms)
|
|
||||||
|
http.FileServer(fs).ServeHTTP(w, r)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -7,6 +7,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/deluan/navidrome/engine"
|
"github.com/deluan/navidrome/engine"
|
||||||
|
"github.com/deluan/navidrome/engine/ffmpeg"
|
||||||
"github.com/deluan/navidrome/persistence"
|
"github.com/deluan/navidrome/persistence"
|
||||||
"github.com/deluan/navidrome/scanner"
|
"github.com/deluan/navidrome/scanner"
|
||||||
"github.com/deluan/navidrome/server"
|
"github.com/deluan/navidrome/server"
|
||||||
@@ -41,7 +42,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
|
|||||||
ratings := engine.NewRatings(dataStore)
|
ratings := engine.NewRatings(dataStore)
|
||||||
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
scrobbler := engine.NewScrobbler(dataStore, nowPlayingRepository)
|
||||||
search := engine.NewSearch(dataStore)
|
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)
|
router := subsonic.New(browser, cover, listGenerator, users, playlists, ratings, scrobbler, search, mediaStreamer)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user