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>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
//go:build beep
|
||||
|
||||
package beepaudio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/faiface/beep"
|
||||
"github.com/faiface/beep/flac"
|
||||
"github.com/faiface/beep/mp3"
|
||||
"github.com/faiface/beep/wav"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func DecodeMp3(path string) (s beep.StreamSeekCloser, format beep.Format, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return mp3.Decode(f)
|
||||
}
|
||||
|
||||
func DecodeWAV(path string) (s beep.StreamSeekCloser, format beep.Format, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return wav.Decode(f)
|
||||
}
|
||||
|
||||
func DecodeFLAC(path string) (s beep.StreamSeekCloser, format beep.Format, fileToCleanup string, err error) {
|
||||
// TODO: Turn this into a semi-parallel operation: start playing while still transcoding/copying
|
||||
log.Debug("decode to FLAC", "filename", path)
|
||||
fFmpeg := ffmpeg.New()
|
||||
readCloser, err := fFmpeg.ConvertToFLAC(context.TODO(), path)
|
||||
if err != nil {
|
||||
log.Error("error converting file to FLAC", path, err)
|
||||
return nil, beep.Format{}, "", err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("", "*.flac")
|
||||
|
||||
if err != nil {
|
||||
log.Error("error creating temp file", err)
|
||||
return nil, beep.Format{}, "", err
|
||||
}
|
||||
log.Debug("created tempfile", "filename", tempFile.Name())
|
||||
|
||||
written, err := io.Copy(tempFile, readCloser)
|
||||
if err != nil {
|
||||
log.Error("error coping file", "dest", tempFile.Name())
|
||||
}
|
||||
log.Debug("copy pipe into tempfile", "bytes written", written, "filename", tempFile.Name())
|
||||
|
||||
f, err := os.Open(tempFile.Name())
|
||||
if err != nil {
|
||||
log.Error("could not re-open tempfile", "filename", tempFile.Name())
|
||||
return nil, beep.Format{}, "", err
|
||||
}
|
||||
|
||||
s, format, err = flac.Decode(f)
|
||||
return s, format, tempFile.Name(), err
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build beep
|
||||
|
||||
package beepaudio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/faiface/beep"
|
||||
"github.com/faiface/beep/effects"
|
||||
"github.com/faiface/beep/speaker"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type BeepTrack struct {
|
||||
MediaFile model.MediaFile
|
||||
Ctrl *beep.Ctrl
|
||||
Volume *effects.Volume
|
||||
ActiveStream beep.StreamSeekCloser
|
||||
TempfileToCleanup string
|
||||
SampleRate beep.SampleRate
|
||||
PlaybackDone chan bool
|
||||
}
|
||||
|
||||
func NewTrack(playbackDoneChannel chan bool, mf model.MediaFile) (*BeepTrack, error) {
|
||||
t := BeepTrack{}
|
||||
|
||||
contentType := mf.ContentType()
|
||||
log.Debug("loading track", "trackname", mf.Path, "mediatype", contentType)
|
||||
|
||||
var streamer beep.StreamSeekCloser
|
||||
var format beep.Format
|
||||
var err error
|
||||
var tmpfileToCleanup = ""
|
||||
|
||||
switch contentType {
|
||||
case "audio/mpeg":
|
||||
streamer, format, err = DecodeMp3(mf.Path)
|
||||
case "audio/x-wav":
|
||||
streamer, format, err = DecodeWAV(mf.Path)
|
||||
case "audio/mp4":
|
||||
streamer, format, tmpfileToCleanup, err = DecodeFLAC(mf.Path)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported content type: %s", contentType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// save running stream for closing when switching tracks
|
||||
t.ActiveStream = streamer
|
||||
t.TempfileToCleanup = tmpfileToCleanup
|
||||
|
||||
log.Debug("Setting up audio device")
|
||||
t.Ctrl = &beep.Ctrl{Streamer: streamer, Paused: true}
|
||||
t.Volume = &effects.Volume{Streamer: t.Ctrl, Base: 2}
|
||||
t.SampleRate = format.SampleRate
|
||||
t.PlaybackDone = playbackDoneChannel
|
||||
t.MediaFile = mf
|
||||
|
||||
err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10))
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
log.Debug("speaker.Init() finished")
|
||||
|
||||
go func() {
|
||||
speaker.Play(beep.Seq(t.Volume, beep.Callback(func() {
|
||||
log.Info("Hitting end-of-stream, signalling on channel")
|
||||
t.PlaybackDone <- true
|
||||
log.Debug("Signalling finished")
|
||||
})))
|
||||
log.Debug("dropping out of speaker.Play()")
|
||||
}()
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (t *BeepTrack) String() string {
|
||||
return fmt.Sprintf("Name: %s", t.MediaFile.Path)
|
||||
}
|
||||
|
||||
func (t *BeepTrack) SetVolume(value float64) {
|
||||
speaker.Lock()
|
||||
t.Volume.Volume += value
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
func (t *BeepTrack) Unpause() {
|
||||
speaker.Lock()
|
||||
if t.Ctrl.Paused {
|
||||
t.Ctrl.Paused = false
|
||||
} else {
|
||||
log.Debug("tried to unpause while not paused")
|
||||
}
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
func (t *BeepTrack) Pause() {
|
||||
speaker.Lock()
|
||||
if t.Ctrl.Paused {
|
||||
log.Debug("tried to pause while already paused")
|
||||
} else {
|
||||
t.Ctrl.Paused = true
|
||||
}
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
func (t *BeepTrack) Close() {
|
||||
if t.ActiveStream != nil {
|
||||
log.Debug("closing activ stream")
|
||||
t.ActiveStream.Close()
|
||||
t.ActiveStream = nil
|
||||
}
|
||||
|
||||
speaker.Close()
|
||||
|
||||
if t.TempfileToCleanup != "" {
|
||||
log.Debug("Removing tempfile", "tmpfilename", t.TempfileToCleanup)
|
||||
err := os.Remove(t.TempfileToCleanup)
|
||||
if err != nil {
|
||||
log.Error("error cleaning up tempfile: ", t.TempfileToCleanup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position returns the playback position in seconds
|
||||
func (t *BeepTrack) Position() int {
|
||||
if t.Ctrl.Streamer == nil {
|
||||
log.Debug("streamer is not setup (nil), could not get position")
|
||||
return 0
|
||||
}
|
||||
|
||||
streamer, ok := t.Ctrl.Streamer.(beep.StreamSeeker)
|
||||
if ok {
|
||||
position := t.SampleRate.D(streamer.Position())
|
||||
posSecs := position.Round(time.Second).Seconds()
|
||||
return int(posSecs)
|
||||
} else {
|
||||
log.Debug("streamer is no beep.StreamSeeker, could not get position")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// offset = pd.PlaybackQueue.Offset
|
||||
func (t *BeepTrack) SetPosition(offset int) error {
|
||||
streamer, ok := t.Ctrl.Streamer.(beep.StreamSeeker)
|
||||
if ok {
|
||||
sampleRatePerSecond := t.SampleRate.N(time.Second)
|
||||
nextPosition := sampleRatePerSecond * offset
|
||||
log.Debug("SetPosition", "samplerate", sampleRatePerSecond, "nextPosition", nextPosition)
|
||||
return streamer.Seek(nextPosition)
|
||||
}
|
||||
return fmt.Errorf("streamer is not seekable")
|
||||
}
|
||||
|
||||
func (t *BeepTrack) IsPlaying() bool {
|
||||
return t.Ctrl != nil && !t.Ctrl.Paused
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Track interface {
|
||||
IsPlaying() bool
|
||||
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
Pause()
|
||||
Unpause()
|
||||
Position() int
|
||||
SetPosition(offset int) error
|
||||
Close()
|
||||
}
|
||||
|
||||
type PlaybackDevice struct {
|
||||
ParentPlaybackServer PlaybackServer
|
||||
Default bool
|
||||
User string
|
||||
Name string
|
||||
Method string
|
||||
DeviceName string
|
||||
PlaybackQueue *Queue
|
||||
Gain float32
|
||||
PlaybackDone chan bool
|
||||
ActiveTrack Track
|
||||
TrackSwitcherStarted bool
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
CurrentIndex int
|
||||
Playing bool
|
||||
Gain float32
|
||||
Position int
|
||||
}
|
||||
|
||||
const DefaultGain float32 = 1.0
|
||||
|
||||
var EmptyStatus = DeviceStatus{CurrentIndex: -1, Playing: false, Gain: DefaultGain, Position: 0}
|
||||
|
||||
func (pd *PlaybackDevice) getStatus() DeviceStatus {
|
||||
pos := 0
|
||||
if pd.ActiveTrack != nil {
|
||||
pos = pd.ActiveTrack.Position()
|
||||
}
|
||||
return DeviceStatus{
|
||||
CurrentIndex: pd.PlaybackQueue.Index,
|
||||
Playing: pd.isPlaying(),
|
||||
Gain: pd.Gain,
|
||||
Position: pos,
|
||||
}
|
||||
}
|
||||
|
||||
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
|
||||
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
|
||||
// Starts the trackSwitcher goroutine for the device.
|
||||
func NewPlaybackDevice(playbackServer PlaybackServer, name string, method string, deviceName string) *PlaybackDevice {
|
||||
return &PlaybackDevice{
|
||||
ParentPlaybackServer: playbackServer,
|
||||
User: "",
|
||||
Name: name,
|
||||
Method: method,
|
||||
DeviceName: deviceName,
|
||||
Gain: DefaultGain,
|
||||
PlaybackQueue: NewQueue(),
|
||||
PlaybackDone: make(chan bool),
|
||||
TrackSwitcherStarted: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) String() string {
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Get action")
|
||||
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Status(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *PlaybackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
_, err := pd.Clear(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting tracks", ids)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.Add(ctx, ids)
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Start action")
|
||||
|
||||
if !pd.TrackSwitcherStarted {
|
||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||
// Start one trackSwitcher goroutine with each device
|
||||
go func() {
|
||||
pd.trackSwitcherGoroutine()
|
||||
}()
|
||||
pd.TrackSwitcherStarted = true
|
||||
}
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
if pd.isPlaying() {
|
||||
log.Debug("trying to start an already playing track")
|
||||
} else {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
if !pd.PlaybackQueue.IsEmpty() {
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Stop action")
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Skip action", "index", index, "offset", offset)
|
||||
|
||||
wasPlaying := pd.isPlaying()
|
||||
|
||||
if pd.ActiveTrack != nil && wasPlaying {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
|
||||
if index != pd.PlaybackQueue.Index {
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
err := pd.switchActiveTrackByIndex(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
err := pd.ActiveTrack.SetPosition(offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting position", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
_, err = pd.Start(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error starting new track after skipping")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Add action")
|
||||
|
||||
items := model.MediaFiles{}
|
||||
|
||||
for _, id := range ids {
|
||||
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
|
||||
if err != nil {
|
||||
return DeviceStatus{}, err
|
||||
}
|
||||
log.Debug(ctx, "Found mediafile: "+mf.Path)
|
||||
items = append(items, *mf)
|
||||
}
|
||||
pd.PlaybackQueue.Add(items)
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Clear action on: %s", pd))
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
pd.PlaybackQueue.Clear()
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Remove action")
|
||||
// pausing if attempting to remove running track
|
||||
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
|
||||
_, err := pd.Stop(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error stopping running track")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
if index > -1 && index < pd.PlaybackQueue.Size() {
|
||||
pd.PlaybackQueue.Remove(index)
|
||||
} else {
|
||||
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "processing Shuffle action")
|
||||
if pd.PlaybackQueue.Size() > 1 {
|
||||
pd.PlaybackQueue.Shuffle()
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
// Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (pd *PlaybackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing SetGain action. Actual gain: %f, gain to set: %f", pd.Gain, gain))
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.SetVolume(gain)
|
||||
}
|
||||
pd.Gain = gain
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) isPlaying() bool {
|
||||
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) trackSwitcherGoroutine() {
|
||||
log.Info("Starting trackSwitcher goroutine")
|
||||
for {
|
||||
<-pd.PlaybackDone
|
||||
log.Info("track switching detected")
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if !pd.PlaybackQueue.IsAtLastElement() {
|
||||
pd.PlaybackQueue.IncreaseIndex()
|
||||
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
log.Error("error switching track", "error", err)
|
||||
}
|
||||
pd.ActiveTrack.Unpause()
|
||||
} else {
|
||||
log.Debug("There is no song left in the playlist. Finish.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *PlaybackDevice) switchActiveTrackByIndex(index int) error {
|
||||
pd.PlaybackQueue.SetIndex(index)
|
||||
currentTrack := pd.PlaybackQueue.Current()
|
||||
if currentTrack == nil {
|
||||
return fmt.Errorf("could not get current track")
|
||||
}
|
||||
|
||||
track, err := mpv.NewTrack(pd.PlaybackDone, *currentTrack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.ActiveTrack = track
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// mpv --no-audio-display --pause 'Jack Johnson/On And On/01 Times Like These.m4a' --input-ipc-server=/tmp/gonzo.socket
|
||||
const (
|
||||
mpvComdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
)
|
||||
|
||||
func start(args []string) (Executor, error) {
|
||||
log.Debug("Executing mpv command", "cmd", args)
|
||||
j := Executor{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
err := j.start()
|
||||
if err != nil {
|
||||
return Executor{}, err
|
||||
}
|
||||
go j.wait()
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func (j *Executor) Cancel() error {
|
||||
if j.cmd != nil {
|
||||
return j.cmd.Cancel()
|
||||
}
|
||||
return fmt.Errorf("there is non command to cancel")
|
||||
}
|
||||
|
||||
type Executor struct {
|
||||
*io.PipeReader
|
||||
out *io.PipeWriter
|
||||
args []string
|
||||
cmd *exec.Cmd
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (j *Executor) start() error {
|
||||
ctx := context.Background()
|
||||
j.ctx = ctx
|
||||
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
||||
cmd.Stdout = j.out
|
||||
if log.CurrentLevel() >= log.LevelTrace {
|
||||
cmd.Stderr = os.Stderr
|
||||
} else {
|
||||
cmd.Stderr = io.Discard
|
||||
}
|
||||
j.cmd = cmd
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting cmd: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *Executor) wait() {
|
||||
if err := j.cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
||||
} else {
|
||||
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = j.out.Close()
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(cmd, filename string, socketName string) []string {
|
||||
split := strings.Split(fixCmd(cmd), " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%f", filename)
|
||||
s = strings.ReplaceAll(s, "%s", socketName)
|
||||
split[i] = s
|
||||
}
|
||||
|
||||
return split
|
||||
}
|
||||
|
||||
func fixCmd(cmd string) string {
|
||||
split := strings.Split(cmd, " ")
|
||||
var result []string
|
||||
cmdPath, _ := mpvCommand()
|
||||
for _, s := range split {
|
||||
if s == "mpv" || s == "mpv.exe" {
|
||||
result = append(result, cmdPath)
|
||||
} else {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
|
||||
func mpvCommand() (string, error) {
|
||||
mpvOnce.Do(func() {
|
||||
if conf.Server.MPVPath != "" {
|
||||
mpvPath = conf.Server.FFmpegPath
|
||||
mpvPath, mpvErr = exec.LookPath(mpvPath)
|
||||
} else {
|
||||
mpvPath, mpvErr = exec.LookPath("mpv")
|
||||
if errors.Is(mpvErr, exec.ErrDot) {
|
||||
log.Trace("mpv found in current folder '.'")
|
||||
mpvPath, mpvErr = exec.LookPath("./mpv")
|
||||
}
|
||||
}
|
||||
if mpvErr == nil {
|
||||
log.Info("Found mpv", "path", mpvPath)
|
||||
return
|
||||
}
|
||||
})
|
||||
return mpvPath, mpvErr
|
||||
}
|
||||
|
||||
var (
|
||||
mpvOnce sync.Once
|
||||
mpvPath string
|
||||
mpvErr error
|
||||
)
|
||||
|
||||
func TempFileName(prefix, suffix string) string {
|
||||
randBytes := make([]byte, 16)
|
||||
// we can savely ignore the return value since we're loading into a precreated, fixedsized buffer
|
||||
_, _ = rand.Read(randBytes)
|
||||
return filepath.Join(os.TempDir(), prefix+hex.EncodeToString(randBytes)+suffix)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPlayback(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Playback Suite")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn.
|
||||
// It makes use of the BEEP library to do the playback. Major parts are:
|
||||
// - decoder which includes decoding and transcoding of various audio file formats
|
||||
// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ...
|
||||
// - queue a simple playlist
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type PlaybackServer interface {
|
||||
Run(ctx context.Context) error
|
||||
GetDeviceForUser(user string) (*PlaybackDevice, error)
|
||||
GetMediaFile(id string) (*model.MediaFile, error)
|
||||
GetCtx() *context.Context
|
||||
}
|
||||
|
||||
type playbackServer struct {
|
||||
ctx *context.Context
|
||||
datastore model.DataStore
|
||||
playbackDevices []PlaybackDevice
|
||||
}
|
||||
|
||||
// GetInstance returns the playback-server singleton
|
||||
func GetInstance() PlaybackServer {
|
||||
return singleton.GetInstance(func() *playbackServer {
|
||||
return &playbackServer{}
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the playback server which serves request until canceled using the given context
|
||||
func (ps *playbackServer) Run(ctx context.Context) error {
|
||||
ps.datastore = persistence.New(db.Db())
|
||||
devices, err := ps.initDeviceStatus(conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
|
||||
ps.playbackDevices = devices
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(conf.Server.Jukebox.Devices)))
|
||||
log.Info(ctx, "Using default audio device: "+conf.Server.Jukebox.Default)
|
||||
|
||||
ps.ctx = &ctx
|
||||
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCtx produces the context this server was started with. Used for data-retrieval and cancellation
|
||||
func (ps *playbackServer) GetCtx() *context.Context {
|
||||
return ps.ctx
|
||||
}
|
||||
|
||||
func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, defaultDevice string) ([]PlaybackDevice, error) {
|
||||
pbDevices := make([]PlaybackDevice, len(devices))
|
||||
defaultDeviceFound := false
|
||||
|
||||
for idx, audioDevice := range devices {
|
||||
if len(audioDevice) != 3 {
|
||||
return []PlaybackDevice{}, fmt.Errorf("audio device definition ought to contain 3 fields, found: %d ", len(audioDevice))
|
||||
}
|
||||
|
||||
pbDevices[idx] = *NewPlaybackDevice(ps, audioDevice[0], audioDevice[1], audioDevice[2])
|
||||
|
||||
if audioDevice[0] == defaultDevice {
|
||||
pbDevices[idx].Default = true
|
||||
defaultDeviceFound = true
|
||||
}
|
||||
}
|
||||
|
||||
if !defaultDeviceFound {
|
||||
return []PlaybackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
|
||||
}
|
||||
return pbDevices, nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) getDefaultDevice() (*PlaybackDevice, error) {
|
||||
for idx, audioDevice := range ps.playbackDevices {
|
||||
if audioDevice.Default {
|
||||
return &ps.playbackDevices[idx], nil
|
||||
}
|
||||
}
|
||||
return &PlaybackDevice{}, fmt.Errorf("no default device found")
|
||||
}
|
||||
|
||||
// GetMediaFile retrieves the MediaFile given by the id parameter
|
||||
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
|
||||
return ps.datastore.MediaFile(*ps.ctx).Get(id)
|
||||
}
|
||||
|
||||
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
|
||||
func (ps *playbackServer) GetDeviceForUser(user string) (*PlaybackDevice, error) {
|
||||
log.Debug("processing GetDevice")
|
||||
// README: here we might plug-in the user-device mapping one fine day
|
||||
device, err := ps.getDefaultDevice()
|
||||
if err != nil {
|
||||
return &PlaybackDevice{}, err
|
||||
}
|
||||
device.User = user
|
||||
return device, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Queue struct {
|
||||
Index int
|
||||
Items model.MediaFiles
|
||||
}
|
||||
|
||||
func NewQueue() *Queue {
|
||||
return &Queue{
|
||||
Index: -1,
|
||||
Items: model.MediaFiles{},
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Queue) String() string {
|
||||
filenames := ""
|
||||
for idx, item := range pd.Items {
|
||||
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
|
||||
}
|
||||
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
|
||||
}
|
||||
|
||||
// returns the current mediafile or nil
|
||||
func (pd *Queue) Current() *model.MediaFile {
|
||||
if pd.Index == -1 {
|
||||
return nil
|
||||
}
|
||||
if pd.Index >= len(pd.Items) {
|
||||
log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items))
|
||||
return nil
|
||||
}
|
||||
|
||||
return &pd.Items[pd.Index]
|
||||
}
|
||||
|
||||
// returns the whole queue
|
||||
func (pd *Queue) Get() model.MediaFiles {
|
||||
return pd.Items
|
||||
}
|
||||
|
||||
func (pd *Queue) Size() int {
|
||||
return len(pd.Items)
|
||||
}
|
||||
|
||||
func (pd *Queue) IsEmpty() bool {
|
||||
return len(pd.Items) < 1
|
||||
}
|
||||
|
||||
// set is similar to a clear followed by a add, but will not change the currently playing track.
|
||||
func (pd *Queue) Set(items model.MediaFiles) {
|
||||
pd.Clear()
|
||||
pd.Items = append(pd.Items, items...)
|
||||
}
|
||||
|
||||
// adding mediafiles to the queue
|
||||
func (pd *Queue) Add(items model.MediaFiles) {
|
||||
pd.Items = append(pd.Items, items...)
|
||||
if pd.Index == -1 && len(pd.Items) > 0 {
|
||||
pd.Index = 0
|
||||
}
|
||||
}
|
||||
|
||||
// empties whole queue
|
||||
func (pd *Queue) Clear() {
|
||||
pd.Index = -1
|
||||
pd.Items = nil
|
||||
}
|
||||
|
||||
// idx Zero-based index of the song to skip to or remove.
|
||||
func (pd *Queue) Remove(idx int) {
|
||||
current := pd.Current()
|
||||
backupID := ""
|
||||
if current != nil {
|
||||
backupID = current.ID
|
||||
}
|
||||
|
||||
pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...)
|
||||
|
||||
var err error
|
||||
pd.Index, err = pd.getMediaFileIndexByID(backupID)
|
||||
if err != nil {
|
||||
// we seem to have deleted the current id, setting to default:
|
||||
pd.Index = -1
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Queue) Shuffle() {
|
||||
current := pd.Current()
|
||||
backupID := ""
|
||||
if current != nil {
|
||||
backupID = current.ID
|
||||
}
|
||||
|
||||
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
|
||||
|
||||
var err error
|
||||
pd.Index, err = pd.getMediaFileIndexByID(backupID)
|
||||
if err != nil {
|
||||
log.Error("Could not find ID while shuffling: " + backupID)
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
|
||||
for idx, item := range pd.Items {
|
||||
if item.ID == id {
|
||||
return idx, nil
|
||||
}
|
||||
}
|
||||
return -1, fmt.Errorf("ID not found in playlist: " + id)
|
||||
}
|
||||
|
||||
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
|
||||
// values above will be limited by number of items.
|
||||
func (pd *Queue) SetIndex(idx int) {
|
||||
pd.Index = max(0, min(idx, len(pd.Items)-1))
|
||||
}
|
||||
|
||||
// Are we at the last track?
|
||||
func (pd *Queue) IsAtLastElement() bool {
|
||||
return (pd.Index + 1) >= len(pd.Items)
|
||||
}
|
||||
|
||||
// Goto next index
|
||||
func (pd *Queue) IncreaseIndex() {
|
||||
if !pd.IsAtLastElement() {
|
||||
pd.SetIndex(pd.Index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func max(x, y int) int {
|
||||
if x < y {
|
||||
return y
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func min(x, y int) int {
|
||||
if x > y {
|
||||
return y
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Queues", func() {
|
||||
var queue *Queue
|
||||
|
||||
BeforeEach(func() {
|
||||
queue = NewQueue()
|
||||
})
|
||||
|
||||
Describe("use empty queue", func() {
|
||||
It("is empty", func() {
|
||||
Expect(queue.Items).To(BeEmpty())
|
||||
Expect(queue.Index).To(Equal(-1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Operate on small queue", func() {
|
||||
BeforeEach(func() {
|
||||
mfs := model.MediaFiles{
|
||||
{
|
||||
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
|
||||
},
|
||||
{
|
||||
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
|
||||
},
|
||||
}
|
||||
queue.Add(mfs)
|
||||
})
|
||||
|
||||
It("contains the preloaded data", func() {
|
||||
Expect(queue.Get).ToNot(BeNil())
|
||||
Expect(queue.Size()).To(Equal(2))
|
||||
})
|
||||
|
||||
It("could read data by ID", func() {
|
||||
idx, err := queue.getMediaFileIndexByID("1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).ToNot(BeNil())
|
||||
Expect(idx).To(Equal(0))
|
||||
|
||||
queue.SetIndex(idx)
|
||||
|
||||
mf := queue.Current()
|
||||
|
||||
Expect(mf).ToNot(BeNil())
|
||||
Expect(mf.ID).To(Equal("1"))
|
||||
Expect(mf.Artist).To(Equal("Queen"))
|
||||
Expect(mf.Path).To(Equal("/music1/hammer.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Read/Write operations", func() {
|
||||
BeforeEach(func() {
|
||||
mfs := model.MediaFiles{
|
||||
{
|
||||
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
|
||||
},
|
||||
{
|
||||
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
|
||||
},
|
||||
{
|
||||
ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3",
|
||||
},
|
||||
{
|
||||
ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3",
|
||||
},
|
||||
{
|
||||
ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3",
|
||||
},
|
||||
}
|
||||
queue.Add(mfs)
|
||||
})
|
||||
|
||||
It("contains the preloaded data", func() {
|
||||
Expect(queue.Get).ToNot(BeNil())
|
||||
Expect(queue.Size()).To(Equal(5))
|
||||
})
|
||||
|
||||
It("could read data by ID", func() {
|
||||
idx, err := queue.getMediaFileIndexByID("5")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).ToNot(BeNil())
|
||||
Expect(idx).To(Equal(4))
|
||||
|
||||
queue.SetIndex(idx)
|
||||
|
||||
mf := queue.Current()
|
||||
|
||||
Expect(mf).ToNot(BeNil())
|
||||
Expect(mf.ID).To(Equal("5"))
|
||||
Expect(mf.Artist).To(Equal("Red Hot Chili Peppers"))
|
||||
Expect(mf.Path).To(Equal("/music1/californication.mp3"))
|
||||
})
|
||||
|
||||
It("could shuffle the data correctly", func() {
|
||||
queue.Shuffle()
|
||||
Expect(queue.Size()).To(Equal(5))
|
||||
})
|
||||
|
||||
It("could remove entries correctly", func() {
|
||||
queue.Remove(0)
|
||||
Expect(queue.Size()).To(Equal(4))
|
||||
|
||||
queue.Remove(3)
|
||||
Expect(queue.Size()).To(Equal(3))
|
||||
})
|
||||
|
||||
It("clear the whole thing on request", func() {
|
||||
Expect(queue.Size()).To(Equal(5))
|
||||
queue.Clear()
|
||||
Expect(queue.Size()).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user