mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-03 06:15:22 +00:00
Merge 5336888a45ae5646411d71729c26c9307065213f into 735c0d910395c9e397bbdde5edac96d8f7bfcf18
This commit is contained in:
commit
81a96323fb
@ -2,13 +2,9 @@ package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Track interface {
|
||||
@ -20,6 +16,7 @@ type Track interface {
|
||||
SetPosition(offset int) error
|
||||
Close()
|
||||
String() string
|
||||
LoadFile(append bool, playNow bool)
|
||||
}
|
||||
|
||||
type playbackDevice struct {
|
||||
@ -33,7 +30,6 @@ type playbackDevice struct {
|
||||
Gain float32
|
||||
PlaybackDone chan bool
|
||||
ActiveTrack Track
|
||||
startTrackSwitcher sync.Once
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
@ -46,13 +42,17 @@ type DeviceStatus struct {
|
||||
const DefaultGain float32 = 1.0
|
||||
|
||||
func (pd *playbackDevice) getStatus() DeviceStatus {
|
||||
currentIndex := pd.ParentPlaybackServer.GetPlaylistPosition()
|
||||
|
||||
pos := 0
|
||||
if pd.ActiveTrack != nil {
|
||||
pos = pd.ActiveTrack.Position()
|
||||
isPlaying := false
|
||||
if currentIndex >= 0 {
|
||||
pos = pd.ParentPlaybackServer.Position()
|
||||
isPlaying = pd.isPlaying()
|
||||
}
|
||||
return DeviceStatus{
|
||||
CurrentIndex: pd.PlaybackQueue.Index,
|
||||
Playing: pd.isPlaying(),
|
||||
CurrentIndex: currentIndex,
|
||||
Playing: isPlaying,
|
||||
Gain: pd.Gain,
|
||||
Position: pos,
|
||||
}
|
||||
@ -75,16 +75,16 @@ func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) String() string {
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f", pd.Name, pd.Gain)
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
func (pd *playbackDevice) Get(ctx context.Context) ([]string, DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Get action", "device", pd)
|
||||
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
|
||||
return pd.ParentPlaybackServer.GetPlaylistIDs(), 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))
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s", pd))
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
@ -102,29 +102,10 @@ func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus,
|
||||
|
||||
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Start action", "device", pd)
|
||||
|
||||
pd.startTrackSwitcher.Do(func() {
|
||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||
// Start one trackSwitcher goroutine with each device
|
||||
go func() {
|
||||
pd.trackSwitcherGoroutine()
|
||||
}()
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
_, err := pd.ParentPlaybackServer.Start()
|
||||
if err != nil {
|
||||
log.Error(ctx, "error starting playback", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
@ -132,47 +113,21 @@ func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
|
||||
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Stop action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
_, err := pd.ParentPlaybackServer.Stop()
|
||||
if err != nil {
|
||||
log.Error(ctx, "error stopping playback", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
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, "device", pd)
|
||||
|
||||
wasPlaying := pd.isPlaying()
|
||||
|
||||
if pd.ActiveTrack != nil && wasPlaying {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
|
||||
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if pd.ActiveTrack == nil {
|
||||
err := pd.switchActiveTrackByIndex(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
err := pd.ActiveTrack.SetPosition(offset)
|
||||
_, err := pd.ParentPlaybackServer.Skip(index, offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting position", err)
|
||||
log.Error(ctx, "error skipping to track", index, offset)
|
||||
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
|
||||
}
|
||||
|
||||
@ -182,55 +137,44 @@ func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus,
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
items := model.MediaFiles{}
|
||||
|
||||
for _, id := range ids {
|
||||
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
|
||||
if err != nil {
|
||||
return DeviceStatus{}, err
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
log.Debug(ctx, "Found mediafile: "+mf.Path)
|
||||
items = append(items, *mf)
|
||||
_, err = pd.ParentPlaybackServer.LoadFile(mf, true, false)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
pd.PlaybackQueue.Add(items)
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Clear action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
_, err := pd.ParentPlaybackServer.Clear()
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.PlaybackQueue.Clear()
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
|
||||
// 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))
|
||||
_, err := pd.ParentPlaybackServer.Remove(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Shuffle action", "device", pd)
|
||||
if pd.PlaybackQueue.Size() > 1 {
|
||||
pd.PlaybackQueue.Shuffle()
|
||||
_, err := pd.ParentPlaybackServer.Shuffle()
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
@ -238,9 +182,9 @@ func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
// SetGain is 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, "Processing SetGain action", "newGain", gain, "device", pd)
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.SetVolume(gain)
|
||||
_, err := pd.ParentPlaybackServer.SetGain(gain)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.Gain = gain
|
||||
|
||||
@ -248,52 +192,5 @@ func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStat
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) isPlaying() bool {
|
||||
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
||||
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
||||
for {
|
||||
select {
|
||||
case <-pd.PlaybackDone:
|
||||
log.Debug("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", err)
|
||||
}
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
log.Debug("There is no song left in the playlist. Finish.")
|
||||
}
|
||||
case <-pd.serviceCtx.Done():
|
||||
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
|
||||
pd.PlaybackQueue.SetIndex(index)
|
||||
currentTrack := pd.PlaybackQueue.Current()
|
||||
if currentTrack == nil {
|
||||
return errors.New("could not get current track")
|
||||
}
|
||||
|
||||
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.ActiveTrack = track
|
||||
pd.ActiveTrack.SetVolume(pd.Gain)
|
||||
return nil
|
||||
return pd.ParentPlaybackServer.IsPlaying()
|
||||
}
|
||||
|
||||
133
core/playback/mpv/jukebox.lua
Normal file
133
core/playback/mpv/jukebox.lua
Normal file
@ -0,0 +1,133 @@
|
||||
-- reporter.lua -- ROBUST VERSION with Cache Management
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
-- The cache is now a simple map of [filepath] -> database_id
|
||||
local id_cache = {}
|
||||
|
||||
-- This dependency-free JSON encoder is working well.
|
||||
function json_encode(val)
|
||||
local json_val
|
||||
local t = type(val)
|
||||
|
||||
if t == 'string' then
|
||||
json_val = '"' .. string.gsub(val, '"', '\\"') .. '"'
|
||||
elseif t == 'number' or t == 'boolean' then
|
||||
json_val = tostring(val)
|
||||
elseif t == 'nil' then
|
||||
return nil -- Return nil to allow omitting keys
|
||||
elseif t == 'table' then
|
||||
local parts = {}
|
||||
local is_array = true
|
||||
local i = 1
|
||||
for k in pairs(val) do
|
||||
if k ~= i then is_array = false; break; end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
if is_array then
|
||||
for j = 1, #val do
|
||||
local part_val = json_encode(val[j])
|
||||
if part_val then table.insert(parts, part_val) end
|
||||
end
|
||||
json_val = '[' .. table.concat(parts, ',') .. ']'
|
||||
else
|
||||
for k, v in pairs(val) do
|
||||
local part_val = json_encode(v)
|
||||
if part_val then
|
||||
local key = '"' .. tostring(k) .. '":'
|
||||
table.insert(parts, key .. json_encode(v))
|
||||
end
|
||||
end
|
||||
json_val = '{' .. table.concat(parts, ',') .. '}'
|
||||
end
|
||||
else
|
||||
json_val = '"' .. tostring(val) .. '"'
|
||||
end
|
||||
return json_val
|
||||
end
|
||||
-- ### ID CACHE MANAGEMENT FUNCTIONS ###
|
||||
|
||||
-- NEW: This is the primary way your Go app will "attach" an ID to a file.
|
||||
-- It expects two arguments: the file path and the database ID.
|
||||
function attach_id(filepath, database_id)
|
||||
if filepath and database_id then
|
||||
msg.info("Attaching ID '" .. database_id .. "' to file: " .. filepath)
|
||||
id_cache[filepath] = database_id
|
||||
else
|
||||
msg.warn("attach-id called with missing arguments.")
|
||||
end
|
||||
end
|
||||
|
||||
-- This function clears the entire cache.
|
||||
function clear_id_cache()
|
||||
msg.info("Clearing all " .. tostring(#id_cache) .. " items from ID cache.")
|
||||
id_cache = {}
|
||||
end
|
||||
|
||||
-- This function syncs the cache with the current playlist, removing stale entries.
|
||||
function sync_id_cache(name, new_playlist)
|
||||
if not new_playlist then return end
|
||||
|
||||
local playlist_files = {}
|
||||
for _, track in ipairs(new_playlist) do
|
||||
if track.filename then
|
||||
playlist_files[track.filename] = true
|
||||
end
|
||||
end
|
||||
|
||||
for path_key, _ in pairs(id_cache) do
|
||||
if not playlist_files[path_key] then
|
||||
msg.info("Removing stale ID from cache for: " .. path_key)
|
||||
id_cache[path_key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- ### DATA PROVIDER FUNCTIONS ###
|
||||
|
||||
-- NEW: This function returns an ordered list of database IDs for the current playlist.
|
||||
function get_playlist_ids()
|
||||
local mpv_playlist = mp.get_property_native("playlist")
|
||||
local id_list = {}
|
||||
if not mpv_playlist then return id_list end
|
||||
|
||||
for _, track in ipairs(mpv_playlist) do
|
||||
if track.filename and id_cache[track.filename] then
|
||||
-- If we have an ID for this file, add it to the list.
|
||||
table.insert(id_list, id_cache[track.filename])
|
||||
else
|
||||
-- If we don't have an ID, insert null to maintain playlist order.
|
||||
table.insert(id_list, mp.null)
|
||||
end
|
||||
end
|
||||
return id_list
|
||||
end
|
||||
|
||||
-- NEW: This function provides the ordered ID list to your Go application.
|
||||
function update_playlist_ids_property()
|
||||
local id_list = get_playlist_ids()
|
||||
local json_string = json_encode(id_list)
|
||||
|
||||
if json_string and json_string ~= "null" then
|
||||
-- Set the data on a property with a new, more descriptive name.
|
||||
mp.set_property("user-data/ext-playlist-ids", json_string)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- ### REGISTER EVENTS, OBSERVERS, AND SCRIPT MESSAGES ###
|
||||
msg.info("Registering ID-based reporter events and observers...")
|
||||
-- For your Go app to attach data and get the playlist
|
||||
mp.register_script_message("attach-id", attach_id)
|
||||
mp.register_script_message("update_playlist_ids_property", update_playlist_ids_property)
|
||||
|
||||
-- Syncs the ID cache whenever the playlist is changed (add, remove, clear)
|
||||
mp.observe_property('playlist', 'native', sync_id_cache)
|
||||
|
||||
-- Clears the cache completely on shutdown
|
||||
mp.register_event("shutdown", clear_id_cache)
|
||||
|
||||
update_playlist_ids_property()
|
||||
|
||||
msg.info("ID-based event jukebox reporter script loaded.")
|
||||
@ -7,9 +7,12 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dexterlb/mpvipc"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -74,7 +77,7 @@ func (j *Executor) wait() {
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||
func createMPVCommand(deviceName string, socketName string) []string {
|
||||
// Parse the template structure using shell parsing to handle quoted arguments
|
||||
templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
|
||||
if err != nil {
|
||||
@ -85,7 +88,6 @@ func createMPVCommand(deviceName string, filename string, socketName string) []s
|
||||
// Replace placeholders in each parsed argument to preserve spaces in substituted values
|
||||
for i, arg := range templateArgs {
|
||||
arg = strings.ReplaceAll(arg, "%d", deviceName)
|
||||
arg = strings.ReplaceAll(arg, "%f", filename)
|
||||
arg = strings.ReplaceAll(arg, "%s", socketName)
|
||||
templateArgs[i] = arg
|
||||
}
|
||||
@ -99,6 +101,7 @@ func createMPVCommand(deviceName string, filename string, socketName string) []s
|
||||
}
|
||||
}
|
||||
}
|
||||
templateArgs = append(templateArgs, "--script="+getJukeboxScriptPath())
|
||||
|
||||
return templateArgs
|
||||
}
|
||||
@ -124,6 +127,111 @@ func mpvCommand() (string, error) {
|
||||
return mpvPath, mpvErr
|
||||
}
|
||||
|
||||
func getJukeboxScriptPath() string {
|
||||
abs, err := filepath.Abs("./jukebox.lua")
|
||||
if err != nil {
|
||||
log.Error("Failed to find jukebox.lua script", err)
|
||||
return ""
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
type MpvConnection struct {
|
||||
Conn *mpvipc.Connection
|
||||
Exe *Executor
|
||||
CloseCalled bool
|
||||
IPCSocketName string
|
||||
}
|
||||
|
||||
func (t *MpvConnection) isSocketFilePresent() bool {
|
||||
if len(t.IPCSocketName) < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(t.IPCSocketName)
|
||||
return err == nil && fileInfo != nil && !fileInfo.IsDir()
|
||||
}
|
||||
|
||||
func waitForSocket(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("Socket found", "retries", retries, "waitTime", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(end) {
|
||||
return fmt.Errorf("timeout reached: %s", timeout)
|
||||
}
|
||||
time.Sleep(pause)
|
||||
retries += 1
|
||||
}
|
||||
}
|
||||
|
||||
func NewConnection(ctx context.Context, deviceName string) (*MpvConnection, error) {
|
||||
log.Debug("Loading mpv connection")
|
||||
|
||||
if _, err := mpvCommand(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(deviceName, tmpSocketName)
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("Error starting mpv process", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for socket to show up
|
||||
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := mpvipc.NewConnection(tmpSocketName)
|
||||
err = conn.Open()
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
theConn := &MpvConnection{Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
|
||||
|
||||
go func() {
|
||||
conn.WaitUntilClosed()
|
||||
log.Info("Hitting end-of-stream, signalling on channel")
|
||||
|
||||
if !theConn.CloseCalled {
|
||||
log.Debug("Close cleanup")
|
||||
// trying to shutdown mpv process using socket
|
||||
if theConn.isSocketFilePresent() {
|
||||
log.Debug("sending shutdown command")
|
||||
_, err := theConn.Conn.Call("quit")
|
||||
if err != nil {
|
||||
log.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||
|
||||
if theConn.Exe != nil {
|
||||
log.Debug("cancelling executor")
|
||||
err = theConn.Exe.Cancel()
|
||||
if err != nil {
|
||||
log.Warn("Error canceling executor", err)
|
||||
}
|
||||
}
|
||||
removeSocket(theConn.IPCSocketName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return theConn, nil
|
||||
}
|
||||
|
||||
var (
|
||||
mpvOnce sync.Once
|
||||
mpvPath string
|
||||
|
||||
@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -22,6 +21,7 @@ var _ = Describe("MPV", func() {
|
||||
var (
|
||||
testScript string
|
||||
tempDir string
|
||||
scriptPath string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -40,7 +40,7 @@ var _ = Describe("MPV", func() {
|
||||
|
||||
// Create mock MPV script that outputs arguments to stdout
|
||||
testScript = createMockMPVScript(tempDir)
|
||||
|
||||
scriptPath = getJukeboxScriptPath()
|
||||
// Configure test MPV path
|
||||
conf.Server.MPVPath = testScript
|
||||
})
|
||||
@ -48,43 +48,43 @@ var _ = Describe("MPV", func() {
|
||||
Describe("createMPVCommand", func() {
|
||||
Context("with default template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("creates correct command with simple paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles paths with spaces", func() {
|
||||
args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/My Album/01 - Song.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles complex device names", func() {
|
||||
deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1"
|
||||
args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand(deviceName, "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=" + deviceName,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -92,22 +92,22 @@ var _ = Describe("MPV", func() {
|
||||
Context("with snapcast template (issue #3619)", func() {
|
||||
BeforeEach(func() {
|
||||
// This is the template that fails with naive space splitting
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("creates correct command for snapcast integration", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
"--audio-samplerate=48000",
|
||||
"--audio-format=s16",
|
||||
"--ao=pcm",
|
||||
"--ao-pcm-file=/audio/snapcast_fifo",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -115,56 +115,56 @@ var _ = Describe("MPV", func() {
|
||||
Context("with wrapper script template", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case that would break with naive splitting due to quoted arguments
|
||||
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo`
|
||||
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause --input-ipc-server=%s --audio-channels=stereo`
|
||||
})
|
||||
|
||||
It("handles wrapper script paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
"/tmp/mpv.sh",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with extra spaces in template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("handles extra spaces correctly", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
Context("with paths containing spaces in template arguments", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with spaces in the path arguments themselves
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
|
||||
})
|
||||
|
||||
It("handles spaces in quoted template argument paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
// This test reveals the limitation of strings.Fields() - it will split on all spaces
|
||||
// Expected behavior would be to keep the path as one argument
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -172,11 +172,11 @@ var _ = Describe("MPV", func() {
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns nil when shell parsing fails", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(BeNil())
|
||||
})
|
||||
})
|
||||
@ -187,15 +187,17 @@ var _ = Describe("MPV", func() {
|
||||
})
|
||||
|
||||
It("returns empty slice for empty template", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{}))
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("start", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("executes MPV command and captures arguments correctly", func() {
|
||||
@ -203,10 +205,9 @@ var _ = Describe("MPV", func() {
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/test.mp3"
|
||||
socketName := "/tmp/test_socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
args := createMPVCommand(deviceName, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -221,8 +222,8 @@ var _ = Describe("MPV", func() {
|
||||
Expect(lines[1]).To(Equal("--audio-device=auto"))
|
||||
Expect(lines[2]).To(Equal("--no-audio-display"))
|
||||
Expect(lines[3]).To(Equal("--pause"))
|
||||
Expect(lines[4]).To(Equal("/music/test.mp3"))
|
||||
Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket"))
|
||||
Expect(lines[4]).To(Equal("--input-ipc-server=/tmp/test_socket"))
|
||||
Expect(lines[5]).To(Equal("--script=" + scriptPath))
|
||||
})
|
||||
|
||||
It("handles file paths with spaces", func() {
|
||||
@ -230,10 +231,9 @@ var _ = Describe("MPV", func() {
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/My Album/01 - My Song.mp3"
|
||||
socketName := "/tmp/test socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
args := createMPVCommand(deviceName, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -243,13 +243,13 @@ var _ = Describe("MPV", func() {
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket"))
|
||||
Expect(lines).To(ContainElement("--script=" + scriptPath))
|
||||
})
|
||||
|
||||
Context("with complex snapcast configuration", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("passes all snapcast arguments correctly", func() {
|
||||
@ -257,10 +257,9 @@ var _ = Describe("MPV", func() {
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/album/track.flac"
|
||||
socketName := "/tmp/mpv-ctrl-test.socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
args := createMPVCommand(deviceName, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -274,13 +273,13 @@ var _ = Describe("MPV", func() {
|
||||
// Verify all expected arguments are present
|
||||
Expect(lines).To(ContainElement("--no-audio-display"))
|
||||
Expect(lines).To(ContainElement("--pause"))
|
||||
Expect(lines).To(ContainElement("/music/album/track.flac"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket"))
|
||||
Expect(lines).To(ContainElement("--audio-channels=stereo"))
|
||||
Expect(lines).To(ContainElement("--audio-samplerate=48000"))
|
||||
Expect(lines).To(ContainElement("--audio-format=s16"))
|
||||
Expect(lines).To(ContainElement("--ao=pcm"))
|
||||
Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo"))
|
||||
Expect(lines).To(ContainElement("--script=" + scriptPath))
|
||||
})
|
||||
})
|
||||
|
||||
@ -320,38 +319,6 @@ var _ = Describe("MPV", func() {
|
||||
Expect(path).To(Equal(testScript))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NewTrack integration", func() {
|
||||
var testMediaFile model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MPVPath = testScript
|
||||
|
||||
// Create a test media file
|
||||
testMediaFile = model.MediaFile{
|
||||
ID: "test-id",
|
||||
Path: "/music/test.mp3",
|
||||
}
|
||||
})
|
||||
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns error when createMPVCommand fails", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
playbackDone := make(chan bool, 1)
|
||||
_, err := NewTrack(ctx, playbackDone, "auto", testMediaFile)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no mpv command arguments provided"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createMockMPVScript creates a mock script that outputs arguments to stdout
|
||||
|
||||
@ -1,223 +0,0 @@
|
||||
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 (
|
||||
"context"
|
||||
"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(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
|
||||
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
|
||||
|
||||
if _, err := mpvCommand(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName)
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("no mpv command arguments provided")
|
||||
}
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("Error starting mpv process", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for socket to show up
|
||||
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := mpvipc.NewConnection(tmpSocketName)
|
||||
err = conn.Open()
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", 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("Setting volume", "volume", value, "track", t)
|
||||
vol := int(value * 100)
|
||||
|
||||
err := t.Conn.Set("volume", vol)
|
||||
if err != nil {
|
||||
log.Error("Error setting volume", "volume", value, "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Unpause() {
|
||||
log.Debug("Unpausing track", "track", t)
|
||||
err := t.Conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Error("Error unpausing track", "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Pause() {
|
||||
log.Debug("Pausing track", "track", t)
|
||||
err := t.Conn.Set("pause", true)
|
||||
if err != nil {
|
||||
log.Error("Error pausing track", "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Close() {
|
||||
log.Debug("Closing resources", "track", t)
|
||||
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.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||
|
||||
if t.Exe != nil {
|
||||
log.Debug("cancelling executor")
|
||||
err = t.Exe.Cancel()
|
||||
if err != nil {
|
||||
log.Warn("Error canceling executor", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.isSocketFilePresent() {
|
||||
removeSocket(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" {
|
||||
retryCount += 1
|
||||
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting position in track", "track", t, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
pos, ok := position.(float64)
|
||||
if !ok {
|
||||
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
|
||||
return 0
|
||||
} else {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) SetPosition(offset int) error {
|
||||
log.Debug("Setting position", "offset", offset, "track", t)
|
||||
pos := t.Position()
|
||||
if pos == offset {
|
||||
log.Debug("No position difference, skipping operation", "track", t)
|
||||
return nil
|
||||
}
|
||||
err := t.Conn.Set("time-pos", float64(offset))
|
||||
if err != nil {
|
||||
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MpvTrack) IsPlaying() bool {
|
||||
log.Debug("Checking if track is playing", "track", t)
|
||||
pausing, err := t.Conn.Get("pause")
|
||||
if err != nil {
|
||||
log.Error("Problem getting paused status", "track", t, err)
|
||||
return false
|
||||
}
|
||||
|
||||
pause, ok := pausing.(bool)
|
||||
if !ok {
|
||||
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
|
||||
return false
|
||||
}
|
||||
return !pause
|
||||
}
|
||||
|
||||
func waitForSocket(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("Socket found", "retries", retries, "waitTime", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(end) {
|
||||
return fmt.Errorf("timeout reached: %s", timeout)
|
||||
}
|
||||
time.Sleep(pause)
|
||||
retries += 1
|
||||
}
|
||||
}
|
||||
@ -7,30 +7,76 @@ package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
// Define a Go struct that mirrors the data structure returned by the Lua script.
|
||||
type PlaylistTrack struct {
|
||||
// Basic info from mpv's playlist property
|
||||
Filename string `json:"filename"`
|
||||
IsPlaying bool `json:"isPlaying"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
PlaylistIndex int `json:"playlistIndex"`
|
||||
|
||||
// Rich metadata from our Lua cache (optional fields)
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Year string `json:"year,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Track int `json:"track,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
Size float64 `json:"size,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
BitRate float64 `json:"bitRate,omitempty"`
|
||||
}
|
||||
|
||||
type PlaybackServer interface {
|
||||
Run(ctx context.Context) error
|
||||
GetDeviceForUser(user string) (*playbackDevice, error)
|
||||
GetMediaFile(id string) (*model.MediaFile, error)
|
||||
GetConnection() (*mpv.MpvConnection, error)
|
||||
LoadFile(mf *model.MediaFile, append bool, playNow bool) (bool, error)
|
||||
Clear() (bool, error)
|
||||
Remove(index int) (bool, error)
|
||||
Shuffle() (bool, error)
|
||||
SetGain(gain float32) (bool, error)
|
||||
IsPlaying() bool
|
||||
Skip(index int, offset int) (bool, error)
|
||||
Stop() (bool, error)
|
||||
Start() (bool, error)
|
||||
SetPosition(offset int) error
|
||||
Position() int
|
||||
GetPlaylistPosition() int
|
||||
GetPlaylistIDs() []string
|
||||
}
|
||||
|
||||
type playbackServer struct {
|
||||
ctx *context.Context
|
||||
datastore model.DataStore
|
||||
playbackDevices []playbackDevice
|
||||
Conn *mpv.MpvConnection
|
||||
}
|
||||
|
||||
// GetInstance returns the playback-server singleton
|
||||
func GetInstance(ds model.DataStore) PlaybackServer {
|
||||
return singleton.GetInstance(func() *playbackServer {
|
||||
return &playbackServer{datastore: ds}
|
||||
conn, err := mpv.NewConnection(context.Background(), "auto")
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil
|
||||
}
|
||||
return &playbackServer{datastore: ds, Conn: conn}
|
||||
})
|
||||
}
|
||||
|
||||
@ -55,6 +101,11 @@ func (ps *playbackServer) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) GetConnection() (*mpv.MpvConnection, error) {
|
||||
log.Debug("Returning connection")
|
||||
return ps.Conn, nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
|
||||
pbDevices := make([]playbackDevice, max(1, len(devices)))
|
||||
defaultDeviceFound := false
|
||||
@ -125,3 +176,314 @@ func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error)
|
||||
device.User = user
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// LoadFile loads a file into the MPV player, and optionally plays it right away
|
||||
func (ps *playbackServer) LoadFile(mf *model.MediaFile, append bool, playNow bool) (bool, error) {
|
||||
log.Debug("Loading file", "mf", mf, "append", append, "playNow", playNow)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
command := ""
|
||||
if append {
|
||||
command += "append"
|
||||
if playNow {
|
||||
log.Debug("Stopping current file")
|
||||
_, err := conn.Conn.Call("playlist-play-index", "none")
|
||||
if err != nil {
|
||||
log.Error("Error stopping current file", "mf", mf, err)
|
||||
}
|
||||
command += "-play"
|
||||
}
|
||||
} else {
|
||||
command += "replace"
|
||||
}
|
||||
|
||||
log.Debug("Loading file", "mf", mf.AbsolutePath(), "command", command)
|
||||
// Example: Tell Lua about the track's ID
|
||||
_, err = conn.Conn.Call("script-message", "attach-id", mf.AbsolutePath(), mf.ID)
|
||||
if err != nil {
|
||||
log.Error("Error attaching ID", "mf", mf, err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = conn.Conn.Call("loadfile", mf.AbsolutePath(), command)
|
||||
if err != nil {
|
||||
log.Error("Error loading file", "mf", mf, err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Clear clears the playlist
|
||||
func (ps *playbackServer) Clear() (bool, error) {
|
||||
log.Debug("Clearing playlist")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("stop")
|
||||
if err != nil {
|
||||
log.Error("Error stopping current file", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Remove the playlist entry at the given index. Index values start counting with 0.
|
||||
// The special value current removes the current entry.
|
||||
// Note that removing the current entry also stops playback and starts playing the next entry.
|
||||
func (ps *playbackServer) Remove(index int) (bool, error) {
|
||||
log.Debug("Removing file", "index", index)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("playlist-remove", index)
|
||||
if err != nil {
|
||||
log.Error("Error removing file", "index", index, err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (ps *playbackServer) SetGain(gain float32) (bool, error) {
|
||||
// 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.
|
||||
vol := int(gain * 100)
|
||||
log.Debug("Setting volume", "volume", vol)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
err = conn.Conn.Set("volume", vol)
|
||||
if err != nil {
|
||||
log.Error("Error setting volume", "volume", vol, err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Shuffle shuffles the playlist
|
||||
func (ps *playbackServer) Shuffle() (bool, error) {
|
||||
log.Debug("Shuffling playlist")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("playlist-shuffle")
|
||||
if err != nil {
|
||||
log.Error("Error shuffling playlist", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsPlaying checks if the currently playing track is paused
|
||||
func (ps *playbackServer) IsPlaying() bool {
|
||||
log.Debug("Checking if track is playing")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false
|
||||
}
|
||||
pausing, err := conn.Conn.Get("pause")
|
||||
if err != nil {
|
||||
log.Error("Problem getting paused status", err)
|
||||
return false
|
||||
}
|
||||
|
||||
pause, ok := pausing.(bool)
|
||||
if !ok {
|
||||
log.Error("Could not cast pausing to boolean", "value", pausing)
|
||||
return false
|
||||
}
|
||||
log.Debug("Checked if track is playing", "pausing", pause)
|
||||
return !pause
|
||||
}
|
||||
|
||||
// Skip skips to the given track
|
||||
func (ps *playbackServer) Skip(index int, offset int) (bool, error) {
|
||||
log.Debug("Skipping to track", "index", index, "offset", offset)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("playlist-play-index", index)
|
||||
if err != nil {
|
||||
log.Error("Error skipping to track", "index", index, err)
|
||||
return false, err
|
||||
}
|
||||
if offset > 0 {
|
||||
_, err = conn.Conn.Call("seek", offset, "absolute")
|
||||
if err != nil {
|
||||
log.Error("Error skipping to offset", "offset", offset, err)
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Stop stops the currently playing track
|
||||
func (ps *playbackServer) Stop() (bool, error) {
|
||||
log.Debug("Stopping track")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
err = conn.Conn.Set("pause", true)
|
||||
if err != nil {
|
||||
log.Error("Error stopping track", "err", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Start starts the currently playing track
|
||||
func (ps *playbackServer) Start() (bool, error) {
|
||||
log.Debug("Starting track")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
err = conn.Conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Error("Error starting track", "err", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 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 (ps *playbackServer) Position() int {
|
||||
retryCount := 0
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return 0
|
||||
}
|
||||
for {
|
||||
position, err := conn.Conn.Get("time-pos")
|
||||
if err != nil && err.Error() == "mpv error: property unavailable" {
|
||||
retryCount += 1
|
||||
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting position in track", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
pos, ok := position.(float64)
|
||||
if !ok {
|
||||
log.Error("Could not cast position from mpv into float64", "position", position)
|
||||
return 0
|
||||
} else {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetPosition sets the position in the currently playing track
|
||||
func (ps *playbackServer) SetPosition(offset int) error {
|
||||
log.Debug("Setting position", "offset", offset)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return err
|
||||
}
|
||||
_, err = conn.Conn.Call("seek", float64(offset), "absolute")
|
||||
if err != nil {
|
||||
log.Error("Error setting position", "offset", offset, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current position on playlist. The first entry is on position 0.
|
||||
func (ps *playbackServer) GetPlaylistPosition() int {
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return 0
|
||||
}
|
||||
position, err := conn.Conn.Get("playlist-pos")
|
||||
if err != nil {
|
||||
log.Error("Error getting current position", err)
|
||||
return 0
|
||||
}
|
||||
return int(position.(float64))
|
||||
}
|
||||
|
||||
// This function now retrieves an ordered list of database IDs from the mpv playlist.
|
||||
// The return type has been changed from []PlaylistTrack to []string.
|
||||
func (ps *playbackServer) GetPlaylistIDs() []string {
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting mpv connection", "error", err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Call the new, renamed script message
|
||||
_, err = conn.Conn.Call("script-message", "update_playlist_ids_property")
|
||||
if err != nil {
|
||||
log.Error("Error calling mpv script 'update_playlist_ids_property'", "error", err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Poll for the property with a retry loop to avoid a race condition
|
||||
var result interface{}
|
||||
maxRetries := 5
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
// Use the new, renamed property. Using GetProperty is slightly safer.
|
||||
result, err = conn.Conn.Get("user-data/ext-playlist-ids")
|
||||
if err == nil && result != nil {
|
||||
break // Success!
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting 'user-data/ext-playlist-ids' property after retries", "error", err)
|
||||
return []string{}
|
||||
}
|
||||
if result == nil {
|
||||
log.Error("Property 'user-data/ext-playlist-ids' was still nil after retries.")
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// The result from mpv is the JSON string of the ID list.
|
||||
jsonString, ok := result.(string)
|
||||
if !ok {
|
||||
log.Error("Expected a string from 'ext-playlist-ids' property but got something else", "type", fmt.Sprintf("%T", result))
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Unmarshal the JSON array of strings into a slice of strings.
|
||||
var idList []string
|
||||
if err := json.Unmarshal([]byte(jsonString), &idList); err != nil {
|
||||
log.Error("Error unmarshalling playlist ID JSON into slice of strings", "error", err, "data", jsonString)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
log.Info("Successfully retrieved playlist IDs from mpv.", "id_count", len(idList))
|
||||
return idList
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
@ -166,6 +167,33 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
return
|
||||
}
|
||||
|
||||
// childFromPlaylistTrack finds a media file in the database based on the path from an
|
||||
// mpv PlaylistTrack, and then uses the original childFromMediaFile to perform the full conversion.
|
||||
// This is the correct, robust way to handle the conversion.
|
||||
func (api *Router) childFromPlaylistTrack(ctx context.Context, ptId string) responses.Child {
|
||||
log.Debug(ctx, "childFromPlaylistTrack", "track", ptId)
|
||||
if ptId == "" {
|
||||
return responses.Child{}
|
||||
}
|
||||
|
||||
// Step 1: Use the path from the mpv track to find the full MediaFile in the database.
|
||||
mf, err := api.ds.MediaFile(ctx).Get(ptId)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not find media file in database for path from mpv", "filename", ptId, "error", err)
|
||||
return responses.Child{}
|
||||
}
|
||||
// Step 2: Once we have the full MediaFile object, call the original, trusted function.
|
||||
// This reuses the existing logic and ensures all database-backed fields are populated.
|
||||
log.Debug(ctx, "childFromPlaylistTrack retrieved", "mediafile", mf)
|
||||
child := childFromMediaFile(ctx, *mf)
|
||||
|
||||
// Optional Step 3: We can override fields with more current data from mpv if desired.
|
||||
// For example, if the user is playing a transcoded stream, the bitrate might differ.
|
||||
// For now, we will trust the database record and the original function.
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
|
||||
@ -52,14 +52,15 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
switch actionString {
|
||||
case ActionGet:
|
||||
mediafiles, status, err := pb.Get(ctx)
|
||||
playlistTrackIds, status, err := pb.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info(ctx, "JukeboxControl get", "playlistTracks", playlistTrackIds, "status", status)
|
||||
|
||||
playlist := responses.JukeboxPlaylist{
|
||||
JukeboxStatus: *deviceStatusToJukeboxStatus(status),
|
||||
Entry: slice.MapWithArg(mediafiles, ctx, childFromMediaFile),
|
||||
Entry: slice.MapWithArg(playlistTrackIds, ctx, api.childFromPlaylistTrack),
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user