Merge 5336888a45ae5646411d71729c26c9307065213f into 735c0d910395c9e397bbdde5edac96d8f7bfcf18

This commit is contained in:
Daniel Evans 2026-01-01 07:20:15 +08:00 committed by GitHub
commit 81a96323fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 714 additions and 441 deletions

View File

@ -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()
}

View 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.")

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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()