diff --git a/core/playback/device.go b/core/playback/device.go index fd08b340e..dd4919fb1 100644 --- a/core/playback/device.go +++ b/core/playback/device.go @@ -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() } diff --git a/core/playback/mpv/jukebox.lua b/core/playback/mpv/jukebox.lua new file mode 100644 index 000000000..2968d4609 --- /dev/null +++ b/core/playback/mpv/jukebox.lua @@ -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.") \ No newline at end of file diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index f356a1410..758042119 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -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 diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go index 20c02501b..dd12de890 100644 --- a/core/playback/mpv/mpv_test.go +++ b/core/playback/mpv/mpv_test.go @@ -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 diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go deleted file mode 100644 index 14170efd4..000000000 --- a/core/playback/mpv/track.go +++ /dev/null @@ -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 - } -} diff --git a/core/playback/playbackserver.go b/core/playback/playbackserver.go index 7dd02dcb1..2caf29361 100644 --- a/core/playback/playbackserver.go +++ b/core/playback/playbackserver.go @@ -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 +} diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index f9733bb3f..565c29d1c 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -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 diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index c4bc643ab..7a85f847a 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -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()