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:] }