navidrome/core/playback/mpv/track.go
Matthias Schmidt 1b16e1140f
Jukebox mode (#2289)
* Adding cache directory to ignore-list

* Adding jukebox-related config options

* Adding DevEnableJukebox config option pls. dummy server

* Adding types and routers

* Now without panic

* First draft on parsing the action

* Some cleanups

* Adding playback server

* Verify audio device configuration

* Adding debug-build target to have full symbol support

* Adding beep sound library pls some example code. Not working yet

* Play a fixed mp3 on any interface access for testing purposes

* Put action code into separate file, adding stringer, more debug output, prepare structs, validation

* Put action parameter parser code where it belongs

* Have a single Action transporting all information

* User fmt.Errorf for error-generation

* Adding wide playback interface

* Use action map for parsing, stringer instead switch stmt.

* Use but only one switch case and direct dispatch, refactoring

* Add error handling and pushing to client

* send decent errormessage, no internal server error

* Adding playback devices slice and load it from config

* Combine config-verification and structure init

* Return user-specific device

* Separate playback server from device

* Use dataStore to retrieve mediafile by id

* WIP: Playlist and start/stop handling. Doing start/stop the hard way as of now

* WIP: set, start and stop work on one single song. More to come

* Dont need to wait for the end

* Merge jukebox_action.go into jukebox.go

* Remove getParameterAsInt64(). Use existing requiredParamInt() instead

* Dont need to call newFailure() explicitly

* Remove int64, use int instead.

* Add and set action now accept multiple ids

* Kickout copy of childFromMediaFile(). It is not needed here.

* Refactoring devices and playbackServer

* Turn (internal) playback.DeviceStatus into subsonic JukeboxStatus when rendering output. Indexes int64 -> int

* Now we have a position and playing status

* Switching gain to float32, xs:float is defined as 32 bit. Fixing nasty copy/pointer bug

* Now with volume control

* Start working the queue

* Remove user from device interface

* Rename function GetDevice -> GetDeviceForUser to make intention clearer

* Have a nice stringer for the queue

* User Prepared boolean for now to allow pause/unpause

* Skipping works, but without offsets

* Make ChildFromMediaFile public to be used in jukebox get() implementation

* Return position in seconds and implement offset-skip in seconds

* Default offset to 0

* Adding a simple setGain implementation

* Prepare for transcoding AAC

* WIP: transcode to WAV to use beeps wav decoder. Not done yet.

* WIP: out of sheer desparation: convert to MP3 (which works) rather than WAV to troubleshoot issue.

* Use FLAC as intermediate format to play Apple AAC

* A bit of cleanup

* Catching the end-of-stream event for further reactions

* Have a trackSwitching goroutine waiting on channel when track ends

* Move decoder code into own file. Restructure code a bit

* Now with going on to play the next song in the playlist

* Adding shuffle feature

* Implementing remove action

* Cleanup code

* Remove templates for ffmpeg mp3 generation. Not needed anymore.

* Adding some documentation

* Check whether offset into track is in range. Fixing potential remove track bug. Documentation

* Make golangci-lint happy: handling return values

* Adding test suite and example dummy for playback package

* Adding some basic queue tests

* Only use Jukebox.Enabled config option

* Adding stream closing handling

* Pass context.Context to all PlaybackDevice methods

* Remove unneeded function

* Correct spelling

* Reduce visibility of ChildFromMediaFile

* Decomplicate action-parsing

* Adding simple tempfile-based AAC->FLAC transcoding. No parallel reading and writing yet.

* Try to optimize pipe-writing, tempfile-handling and reading. Not done yet.

* Do a synchronous copy of the tempfile. Racecondition detected

* More debugging statements and fixing the play/pause bug. More work needed

* Start the trackSwitcher() with each device once. Return JSON position even if its 0. More debug-output

* Moving all track-handling code into own module

* Fix typo. Do not pass ctx around when not applicable

* WIP: More refactoring, debugging output

* Fix nil pointer

* Repairing MP3 playback by pinning indirect dependencies: hajimehoshi/go-mp3 and hajimehoshi/oto

* Do not forget to cleanup after a skip action

* Make resync with master easy

* Adding missing mocks

* Adding missing error-handling found by linter

* Updating github.com/hajimehoshi/oto

* Removing duplicate function

* Move BEEP-related code into own package

* Juggle beep-related code around as preparation for interface access

* More refactoring for interface separation

* Gather CloseDevice() behind Track interface.

* Adding skeleton, draft audio-interface using mpv.io

* Adding majority of interface commands using messages to mpv socket.

* Adding end-of-stream handling

* MPV: start/stop are working

* postition is given in float in mpv

* Unify Close() and CloseDevice(). Using temp filename for controlling socket

* Wait until control-socket shows up. Cleanup socket in Close()

* Use canceable command. Rename to Executor

* Skipping tracks works now

* Now with actually setting the position

* Fix regain

* Add missing error-handling found by linter

* Adding retry mode on time-pos property getter

* Remove unneeded code on queue

* Putting build-tag beep onto beep files

* Remove deprecated call to rand.Seed()

"As of Go 1.20 there is no reason to call Seed with a random value. Programs that call Seed with a known value to get a specific sequence of results should use New(NewSource(seed)) to obtain a local random generator."

* Using int32 to conform to Subsonic API spec

* Fix merge error

* Minor style changes

* Get username from context

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-09-10 11:25:22 -04:00

223 lines
5.4 KiB
Go

package mpv
// Audio-playback using mpv media-server. See mpv.io
// https://github.com/dexterlb/mpvipc
// https://mpv.io/manual/master/#json-ipc
// https://mpv.io/manual/master/#properties
import (
"fmt"
"os"
"time"
"github.com/DexterLB/mpvipc"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type MpvTrack struct {
MediaFile model.MediaFile
PlaybackDone chan bool
Conn *mpvipc.Connection
IPCSocketName string
Exe *Executor
CloseCalled bool
}
func NewTrack(playbackDoneChannel chan bool, mf model.MediaFile) (*MpvTrack, error) {
log.Debug("loading track", "trackname", mf.Path, "mediatype", mf.ContentType())
if _, err := mpvCommand(); err != nil {
return nil, err
}
tmpSocketName := TempFileName("mpv-ctrl-", ".socket")
args := createMPVCommand(mpvComdTemplate, mf.Path, tmpSocketName)
exe, err := start(args)
if err != nil {
log.Error("error starting mpv process", "error", err)
return nil, err
}
// wait for socket to show up
err = waitForFile(tmpSocketName, 3*time.Second, 100*time.Millisecond)
if err != nil {
log.Error("error or timeout waiting for control socket", "socketname", tmpSocketName, "error", err)
return nil, err
}
conn := mpvipc.NewConnection(tmpSocketName)
err = conn.Open()
if err != nil {
log.Error("error opening new connection", "error", err)
return nil, err
}
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
go func() {
conn.WaitUntilClosed()
log.Info("Hitting end-of-stream, signalling on channel")
if !theTrack.CloseCalled {
playbackDoneChannel <- true
}
}()
return theTrack, nil
}
func (t *MpvTrack) String() string {
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
}
// Used to control the playback volume. A float value between 0.0 and 1.0.
func (t *MpvTrack) SetVolume(value float32) {
// mpv's volume as described in the --volume parameter:
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
// Negative values can be passed for compatibility, but are treated as 0.
log.Debug("request for gain", "gain", value)
vol := int(value * 100)
err := t.Conn.Set("volume", vol)
if err != nil {
log.Error(err)
}
log.Debug("set volume", "volume", vol)
}
func (t *MpvTrack) Unpause() {
err := t.Conn.Set("pause", false)
if err != nil {
log.Error(err)
}
log.Info("unpaused track")
}
func (t *MpvTrack) Pause() {
err := t.Conn.Set("pause", true)
if err != nil {
log.Error(err)
}
log.Info("paused track")
}
func (t *MpvTrack) Close() {
log.Debug("closing resources")
t.CloseCalled = true
// trying to shutdown mpv process using socket
if t.isSocketfilePresent() {
log.Debug("sending shutdown command")
_, err := t.Conn.Call("quit")
if err != nil {
log.Error("error sending quit command to mpv-ipc socket", "error", err)
if t.Exe != nil {
log.Debug("cancelling executor")
err = t.Exe.Cancel()
if err != nil {
log.Error("error canceling executor")
}
}
}
}
if t.isSocketfilePresent() {
log.Debug("Removing socketfile", "socketfile", t.IPCSocketName)
err := os.Remove(t.IPCSocketName)
if err != nil {
log.Error("error cleaning up socketfile: ", t.IPCSocketName)
}
}
}
func (t *MpvTrack) isSocketfilePresent() bool {
if len(t.IPCSocketName) < 1 {
return false
}
fileInfo, err := os.Stat(t.IPCSocketName)
return err == nil && fileInfo != nil && !fileInfo.IsDir()
}
// Position returns the playback position in seconds
// every now and then the mpv IPC interface returns "mpv error: property unavailable"
// in this case we have to retry
func (t *MpvTrack) Position() int {
retryCount := 0
for {
position, err := t.Conn.Get("time-pos")
if err != nil && err.Error() == "mpv error: property unavailable" {
log.Debug("got the mpv error: property unavailable error, retry ...")
retryCount += 1
if retryCount > 5 {
return 0
}
break
}
if err != nil {
log.Error("error getting position in track", "error", err)
return 0
}
pos, ok := position.(float64)
if !ok {
log.Error("could not cast position from mpv into float64")
return 0
} else {
return int(pos)
}
}
return 0
}
func (t *MpvTrack) SetPosition(offset int) error {
pos := t.Position()
if pos == offset {
log.Debug("no position difference, skipping operation")
return nil
}
err := t.Conn.Set("time-pos", float64(offset))
if err != nil {
log.Error("could not set the position in track", "offset", offset, "error", err)
return err
}
log.Info("set position", "offset", offset)
return nil
}
func (t *MpvTrack) IsPlaying() bool {
pausing, err := t.Conn.Get("pause")
if err != nil {
log.Error("problem getting paused status", "error", err)
return false
}
pause, ok := pausing.(bool)
if !ok {
log.Error("could not cast pausing to boolean")
return false
}
return !pause
}
func waitForFile(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
var retries int = 0
for {
fileInfo, err := os.Stat(path)
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
log.Debug("file found", "retries", retries, "waittime", time.Since(start).Microseconds())
return nil
}
if time.Now().After(end) {
return fmt.Errorf("timeout reached: %s", timeout)
}
time.Sleep(pause)
retries += 1
}
}