Skip to content

Commit 0d40c92

Browse files
b0bbywanclaude
andcommitted
feat(mpris): handle TrackList D-Bus signals in the listener
Wire TrackListReplaced/TrackAdded/TrackRemoved/TrackMetadataChanged to the cache mutators, resolving senders via findPlayerByUniqueName (the safety net for the broad interface+path match rule). TrackListReplaced refetches metadata in one GetTracksMetadata call, keeping IDs only on failure. PropertiesChanged on the TrackList interface updates CanEditTracks and also covers players that never deliver usable TrackList signals: VLC emits them under a mangled interface name (MediaPlayer2.TrackList, missing the org.mpris prefix) the match rule can't see, and reports queue changes as a Tracks value or a bare invalidation instead. A changed value refreshes the cache directly, an invalidation refetches the property first; comparing IDs against the cache before refreshing keeps players that emit proper signals (mpd2mpris) from double-updating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sathks1sgo6iJF16zpiewx
1 parent 748fffa commit 0d40c92

4 files changed

Lines changed: 293 additions & 8 deletions

File tree

backend/mpris/dbus.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ func (m *MPRISBackend) getNameOwner(busName string) (string, error) {
124124
return owner, nil
125125
}
126126

127+
// arg extracts sig.Body[i] as T, false if absent or mistyped.
128+
func arg[T any](sig *dbus.Signal, i int) (T, bool) {
129+
if i >= len(sig.Body) {
130+
var zero T
131+
return zero, false
132+
}
133+
v, ok := sig.Body[i].(T)
134+
return v, ok
135+
}
136+
127137
// Value extraction helpers from dbus.Variant
128138
// These helpers are used to extract values from variants received
129139
// in D-Bus signals without making additional D-Bus calls.

backend/mpris/listener.go

Lines changed: 152 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mpris
22

33
import (
44
"context"
5+
"slices"
56
"strings"
67

78
"github.com/godbus/dbus/v5"
@@ -62,6 +63,11 @@ func (l *Listener) handleSignal(sig *dbus.Signal) {
6263
l.handlePropertiesChanged(sig)
6364
case DBUS_NAME_OWNER_CHANGED:
6465
l.handleNameOwnerChanged(sig)
66+
case MPRIS_SIGNAL_TRACKLIST_REPLACED, MPRIS_SIGNAL_TRACK_ADDED,
67+
MPRIS_SIGNAL_TRACK_REMOVED, MPRIS_SIGNAL_TRACK_METADATA_CHANGED:
68+
if busName := l.resolveSender(sig); busName != "" {
69+
l.handleTrackListSignal(busName, sig)
70+
}
6571
default:
6672
logger.Debug("[mpris] unhandled signal: %s", sig.Name)
6773
}
@@ -73,17 +79,12 @@ func (l *Listener) handlePropertiesChanged(sig *dbus.Signal) {
7379
// Body[1] = changed properties (map[string]Variant)
7480
// Body[2] = invalidated properties ([]string)
7581

76-
if len(sig.Body) < 2 {
77-
return
78-
}
79-
80-
iface, ok := sig.Body[0].(string)
81-
if !ok || iface != MPRIS_PLAYER_IFACE {
82-
// We only care about Player changes
82+
iface, ok := arg[string](sig, 0)
83+
if !ok {
8384
return
8485
}
8586

86-
changed, ok := sig.Body[1].(map[string]dbus.Variant)
87+
changed, ok := arg[map[string]dbus.Variant](sig, 1)
8788
if !ok {
8889
return
8990
}
@@ -96,6 +97,31 @@ func (l *Listener) handlePropertiesChanged(sig *dbus.Signal) {
9697
return
9798
}
9899

100+
if iface == MPRIS_TRACKLIST_IFACE {
101+
if v, ok := changed["CanEditTracks"]; ok {
102+
if err := l.backend.UpdateCanEditTracks(busName, v); err != nil {
103+
logger.Error("[mpris] failed to update CanEditTracks for %s: %v", busName, err)
104+
}
105+
}
106+
// Some players (VLC) never emit usable TrackList signals (VLC mangles
107+
// their interface name) and only report queue changes here — as a
108+
// changed value or a bare invalidation. Skipping when the IDs match
109+
// the cache keeps players that emit both from doubling up.
110+
if v, ok := changed["Tracks"]; ok {
111+
if ids, ok := v.Value().([]dbus.ObjectPath); ok && !l.backend.tracklistMatches(busName, ids) {
112+
l.refreshTracklist(busName, ids)
113+
}
114+
return
115+
}
116+
if invalidated, ok := arg[[]string](sig, 2); ok && slices.Contains(invalidated, "Tracks") {
117+
l.refetchTracklist(busName)
118+
}
119+
return
120+
}
121+
if iface != MPRIS_PLAYER_IFACE {
122+
return
123+
}
124+
99125
// Check if PlaybackStatus changed for deduplication
100126
if statusVar, hasStatus := changed["PlaybackStatus"]; hasStatus {
101127
if status, ok := extractString(statusVar); ok {
@@ -178,6 +204,124 @@ func (l *Listener) handleNameOwnerChanged(sig *dbus.Signal) {
178204
}
179205
}
180206

207+
// resolveSender maps a signal's unique-name sender to a cached busName.
208+
// Dropping unknown senders is the safety net for the broad interface+path
209+
// match rule, which delivers TrackList signals from any sender.
210+
func (l *Listener) resolveSender(sig *dbus.Signal) string {
211+
busName := l.backend.findPlayerByUniqueName(sig.Sender)
212+
if busName == "" {
213+
logger.Debug("[mpris] dropping %s from unknown sender %s", sig.Name, sig.Sender)
214+
}
215+
return busName
216+
}
217+
218+
// refreshTracklist replaces a player's cached tracklist from a list of IDs,
219+
// fetching their metadata in one call (IDs only on failure).
220+
func (l *Listener) refreshTracklist(busName string, ids []dbus.ObjectPath) {
221+
tracks := tracksFromIDs(ids)
222+
if len(ids) > 0 {
223+
if metas, err := newPlayer(l.backend, busName).getTracksMetadata(ids); err == nil {
224+
tracks = tracksFromMetadata(ids, metas)
225+
} else {
226+
logger.Debug("[mpris] GetTracksMetadata failed for %s, keeping IDs only: %v", busName, err)
227+
}
228+
}
229+
230+
if err := l.backend.ReplaceTracklist(busName, tracks); err != nil {
231+
logger.Error("[mpris] failed to replace tracklist for %s: %v", busName, err)
232+
}
233+
}
234+
235+
// refetchTracklist re-reads the Tracks property after an invalidation
236+
// (no value in the signal) and refreshes the cache if it changed.
237+
func (l *Listener) refetchTracklist(busName string) {
238+
v, err := l.backend.getProperty(busName, MPRIS_TRACKLIST_IFACE, "Tracks")
239+
if err != nil {
240+
logger.Debug("[mpris] failed to fetch Tracks for %s: %v", busName, err)
241+
return
242+
}
243+
ids, ok := v.Value().([]dbus.ObjectPath)
244+
if !ok {
245+
return
246+
}
247+
if !l.backend.tracklistMatches(busName, ids) {
248+
l.refreshTracklist(busName, ids)
249+
}
250+
}
251+
252+
// handleTrackListSignal dispatches TrackList signals once the sender is
253+
// resolved to a cached player.
254+
func (l *Listener) handleTrackListSignal(busName string, sig *dbus.Signal) {
255+
switch sig.Name {
256+
case MPRIS_SIGNAL_TRACKLIST_REPLACED:
257+
l.handleTrackListReplaced(busName, sig)
258+
case MPRIS_SIGNAL_TRACK_ADDED:
259+
l.handleTrackAdded(busName, sig)
260+
case MPRIS_SIGNAL_TRACK_REMOVED:
261+
l.handleTrackRemoved(busName, sig)
262+
case MPRIS_SIGNAL_TRACK_METADATA_CHANGED:
263+
l.handleTrackMetadataChanged(busName, sig)
264+
}
265+
}
266+
267+
// handleTrackListReplaced processes a wholesale tracklist replacement.
268+
// Body[0] = new track IDs; Body[1] = current track (unused)
269+
func (l *Listener) handleTrackListReplaced(busName string, sig *dbus.Signal) {
270+
ids, ok := arg[[]dbus.ObjectPath](sig, 0)
271+
if !ok {
272+
return
273+
}
274+
275+
l.refreshTracklist(busName, ids)
276+
}
277+
278+
// handleTrackAdded processes a track insertion.
279+
// Body[0] = track metadata; Body[1] = the track it was inserted after
280+
func (l *Listener) handleTrackAdded(busName string, sig *dbus.Signal) {
281+
meta, ok := arg[map[string]dbus.Variant](sig, 0)
282+
if !ok {
283+
return
284+
}
285+
afterTrack, ok := arg[dbus.ObjectPath](sig, 1)
286+
if !ok {
287+
return
288+
}
289+
290+
if err := l.backend.AddTrackToCache(busName, trackFromSignalMetadata(meta), string(afterTrack)); err != nil {
291+
logger.Error("[mpris] failed to add track for %s: %v", busName, err)
292+
}
293+
}
294+
295+
// handleTrackRemoved processes a track removal.
296+
// Body[0] = removed track ID
297+
func (l *Listener) handleTrackRemoved(busName string, sig *dbus.Signal) {
298+
trackID, ok := arg[dbus.ObjectPath](sig, 0)
299+
if !ok {
300+
return
301+
}
302+
303+
if err := l.backend.RemoveTrackFromCache(busName, string(trackID)); err != nil {
304+
logger.Error("[mpris] failed to remove track for %s: %v", busName, err)
305+
}
306+
}
307+
308+
// handleTrackMetadataChanged processes per-track metadata updates.
309+
// Body[0] = track ID; Body[1] = new metadata
310+
func (l *Listener) handleTrackMetadataChanged(busName string, sig *dbus.Signal) {
311+
trackID, ok := arg[dbus.ObjectPath](sig, 0)
312+
if !ok {
313+
return
314+
}
315+
meta, ok := arg[map[string]dbus.Variant](sig, 1)
316+
if !ok {
317+
return
318+
}
319+
320+
if err := l.backend.UpdateTrackMetadataInCache(busName, string(trackID), trackFromSignalMetadata(meta)); err != nil {
321+
logger.Error("[mpris] failed to update track metadata for %s: %v", busName, err)
322+
}
323+
}
324+
181325
// Stop stops the listener
182326
func (l *Listener) Stop() {
183327
logger.Info("[mpris] stopping listener")

backend/mpris/tracklist.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ func tracklistEnvelope(p Player) map[string]any {
5454
}
5555
}
5656

57+
// tracklistMatches reports whether the player's cached tracklist has exactly
58+
// the given IDs in the same order.
59+
func (m *MPRISBackend) tracklistMatches(busName string, ids []dbus.ObjectPath) bool {
60+
player, err := m.GetPlayerFromCache(busName)
61+
if err != nil || len(player.Tracklist) != len(ids) {
62+
return false
63+
}
64+
for i := range ids {
65+
if player.Tracklist[i].TrackID != string(ids[i]) {
66+
return false
67+
}
68+
}
69+
return true
70+
}
71+
5772
// ReplaceTracklist wholesale-replaces a player's tracklist (TrackListReplaced).
5873
func (m *MPRISBackend) ReplaceTracklist(busName string, tracks []Track) error {
5974
return m.mutateTracklist(busName, func(p *Player) bool {

backend/mpris/tracklist_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,122 @@ func TestRemoveTrackValidation(t *testing.T) {
444444
})
445445
}
446446

447+
func TestListenerTracklistSignals(t *testing.T) {
448+
const uniqueName = ":1.42"
449+
450+
newListener := func() (*Listener, *MPRISBackend) {
451+
b := newTracklistBackend(Player{
452+
BusName: testBus,
453+
uniqueName: uniqueName,
454+
TracklistSupported: true,
455+
Tracklist: []Track{{TrackID: "/track/1"}, {TrackID: "/track/2"}},
456+
})
457+
return &Listener{backend: b}, b
458+
}
459+
460+
signal := func(name string, body ...interface{}) *dbus.Signal {
461+
return &dbus.Signal{Sender: uniqueName, Path: MPRIS_PATH, Name: name, Body: body}
462+
}
463+
464+
t.Run("TrackAdded prepends via NoTrack", func(t *testing.T) {
465+
l, b := newListener()
466+
meta := map[string]dbus.Variant{
467+
"mpris:trackid": dbus.MakeVariant(dbus.ObjectPath("/track/new")),
468+
}
469+
l.handleSignal(signal(MPRIS_SIGNAL_TRACK_ADDED, meta, dbus.ObjectPath(MPRIS_NO_TRACK)))
470+
471+
p, _ := b.GetPlayerFromCache(testBus)
472+
assertTrackIDs(t, p.Tracklist, []string{"/track/new", "/track/1", "/track/2"})
473+
})
474+
475+
t.Run("TrackRemoved", func(t *testing.T) {
476+
l, b := newListener()
477+
l.handleSignal(signal(MPRIS_SIGNAL_TRACK_REMOVED, dbus.ObjectPath("/track/1")))
478+
479+
p, _ := b.GetPlayerFromCache(testBus)
480+
assertTrackIDs(t, p.Tracklist, []string{"/track/2"})
481+
})
482+
483+
t.Run("TrackMetadataChanged", func(t *testing.T) {
484+
l, b := newListener()
485+
meta := map[string]dbus.Variant{
486+
"mpris:trackid": dbus.MakeVariant(dbus.ObjectPath("/track/1")),
487+
"xesam:title": dbus.MakeVariant("Fresh"),
488+
}
489+
l.handleSignal(signal(MPRIS_SIGNAL_TRACK_METADATA_CHANGED, dbus.ObjectPath("/track/1"), meta))
490+
491+
p, _ := b.GetPlayerFromCache(testBus)
492+
if p.Tracklist[0].Metadata["xesam:title"] != "Fresh" {
493+
t.Errorf("title = %q, want %q", p.Tracklist[0].Metadata["xesam:title"], "Fresh")
494+
}
495+
})
496+
497+
t.Run("TrackListReplaced with empty list clears", func(t *testing.T) {
498+
l, b := newListener()
499+
l.handleSignal(signal(MPRIS_SIGNAL_TRACKLIST_REPLACED, []dbus.ObjectPath{}, dbus.ObjectPath(MPRIS_NO_TRACK)))
500+
501+
p, _ := b.GetPlayerFromCache(testBus)
502+
assertTrackIDs(t, p.Tracklist, []string{})
503+
})
504+
505+
t.Run("unknown sender is dropped", func(t *testing.T) {
506+
l, b := newListener()
507+
sig := signal(MPRIS_SIGNAL_TRACK_REMOVED, dbus.ObjectPath("/track/1"))
508+
sig.Sender = ":9.99"
509+
l.handleSignal(sig)
510+
511+
p, _ := b.GetPlayerFromCache(testBus)
512+
assertTrackIDs(t, p.Tracklist, []string{"/track/1", "/track/2"})
513+
})
514+
515+
t.Run("malformed bodies are ignored", func(t *testing.T) {
516+
l, b := newListener()
517+
l.handleSignal(signal(MPRIS_SIGNAL_TRACK_ADDED, "not-a-map"))
518+
l.handleSignal(signal(MPRIS_SIGNAL_TRACK_REMOVED))
519+
l.handleSignal(signal(MPRIS_SIGNAL_TRACKLIST_REPLACED, "not-ids", dbus.ObjectPath("/x")))
520+
521+
p, _ := b.GetPlayerFromCache(testBus)
522+
assertTrackIDs(t, p.Tracklist, []string{"/track/1", "/track/2"})
523+
})
524+
525+
t.Run("PropertiesChanged on TrackList iface updates CanEditTracks", func(t *testing.T) {
526+
l, b := newListener()
527+
// Tracks matching the cache must be skipped (dedup for players that
528+
// emit both TrackListReplaced and the property change): with a nil
529+
// conn, going through the refresh would panic on the metadata fetch.
530+
l.handleSignal(signal(DBUS_PROP_CHANGED_SIGNAL, MPRIS_TRACKLIST_IFACE, map[string]dbus.Variant{
531+
"CanEditTracks": dbus.MakeVariant(true),
532+
"Tracks": dbus.MakeVariant([]dbus.ObjectPath{"/track/1", "/track/2"}),
533+
}, []string{}))
534+
535+
p, _ := b.GetPlayerFromCache(testBus)
536+
if !p.CanEditTracks {
537+
t.Error("CanEditTracks should be true")
538+
}
539+
assertTrackIDs(t, p.Tracklist, []string{"/track/1", "/track/2"})
540+
})
541+
542+
t.Run("PropertiesChanged with changed Tracks refreshes the list (VLC style)", func(t *testing.T) {
543+
l, b := newListener()
544+
l.handleSignal(signal(DBUS_PROP_CHANGED_SIGNAL, MPRIS_TRACKLIST_IFACE, map[string]dbus.Variant{
545+
"Tracks": dbus.MakeVariant([]dbus.ObjectPath{}),
546+
}, []string{}))
547+
548+
p, _ := b.GetPlayerFromCache(testBus)
549+
assertTrackIDs(t, p.Tracklist, []string{})
550+
})
551+
552+
t.Run("unrelated invalidated property does not refetch", func(t *testing.T) {
553+
l, b := newListener()
554+
// With a nil conn, a refetch would panic — not reaching D-Bus is the assertion.
555+
l.handleSignal(signal(DBUS_PROP_CHANGED_SIGNAL, MPRIS_TRACKLIST_IFACE,
556+
map[string]dbus.Variant{}, []string{"CanEditTracks"}))
557+
558+
p, _ := b.GetPlayerFromCache(testBus)
559+
assertTrackIDs(t, p.Tracklist, []string{"/track/1", "/track/2"})
560+
})
561+
}
562+
447563
func TestTracklistUnsupportedError(t *testing.T) {
448564
err := &TracklistUnsupportedError{BusName: "org.mpris.MediaPlayer2.spotify"}
449565
expected := "tracklist not supported: org.mpris.MediaPlayer2.spotify"

0 commit comments

Comments
 (0)