mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-01 19:01:35 +03:00
221 lines
5.1 KiB
Go
221 lines
5.1 KiB
Go
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
|
|
pos int64
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
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) 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
|
|
}
|
|
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:]
|
|
}
|