Skip to content

Commit bac935e

Browse files
b0bbywanclaude
andcommitted
fix(pulseaudio): derive client names for streams registering empty ones
Some clients (e.g. spotifyd) connect with empty media.name and application.name, leaving them unnamed in the API and unaddressable through the name-based action routes. Client names now fall back to application.name then the process binary, applied consistently at parse time and in the sink-input lookup; a failed lookup maps to 404. Fixes #130 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKi5652rX9cdUDycdU1btS
1 parent e361485 commit bac935e

3 files changed

Lines changed: 84 additions & 6 deletions

File tree

backend/pulseaudio/pipewire.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func (pa *PulseAudioBackend) parsePipeWireSinkInput(s pulseaudio.SinkInput) Audi
2727

2828
return AudioClient{
2929
ID: s.Index,
30-
Name: props["media.name"],
30+
Name: clientName(props),
3131
App: props["application.name"],
3232
Muted: s.IsMute(),
3333
Volume: s.GetVolume(),

backend/pulseaudio/pulseaudio.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,9 @@ func (pa *PulseAudioBackend) SetVolumeMaster(volume float32) error {
360360

361361
func (pa *PulseAudioBackend) ToggleMute(name string) error {
362362
logger.Debug("[pulseaudio] toggling mute for client %q", name)
363-
sink, err := pa.client.GetSinkInputByName(name)
363+
sink, err := pa.findSinkInput(name)
364364
if err != nil {
365-
return fmt.Errorf("failed to get sink input: %w", err)
365+
return err
366366
}
367367

368368
if err := sink.ToggleMute(); err != nil {
@@ -373,9 +373,9 @@ func (pa *PulseAudioBackend) ToggleMute(name string) error {
373373

374374
func (pa *PulseAudioBackend) SetVolume(name string, vol float32) error {
375375
logger.Debug("[pulseaudio] setting volume for client %q to %.2f", name, vol)
376-
sink, err := pa.client.GetSinkInputByName(name)
376+
sink, err := pa.findSinkInput(name)
377377
if err != nil {
378-
return fmt.Errorf("failed to get sink input: %w", err)
378+
return err
379379
}
380380

381381
if err := sink.SetVolume(vol); err != nil {
@@ -385,6 +385,21 @@ func (pa *PulseAudioBackend) SetVolume(name string, vol float32) error {
385385
return nil
386386
}
387387

388+
// findSinkInput matches a sink input by the same derived name the parsers
389+
// expose, so clients registering empty names stay addressable.
390+
func (pa *PulseAudioBackend) findSinkInput(name string) (pulseaudio.SinkInput, error) {
391+
inputs, err := pa.client.SinkInputs()
392+
if err != nil {
393+
return pulseaudio.SinkInput{}, fmt.Errorf("failed to list sink inputs: %w", err)
394+
}
395+
for _, s := range inputs {
396+
if strings.EqualFold(clientName(s.PropList), name) {
397+
return s, nil
398+
}
399+
}
400+
return pulseaudio.SinkInput{}, &NotFoundError{Resource: "client", Name: name}
401+
}
402+
388403
func (pa *PulseAudioBackend) parseSinkInput(s pulseaudio.SinkInput) AudioClient {
389404
switch pa.kind {
390405
case ServerPipeWire:
@@ -406,7 +421,7 @@ func (pa *PulseAudioBackend) parsePulseSinkInput(s pulseaudio.SinkInput) AudioCl
406421

407422
return AudioClient{
408423
ID: s.Index,
409-
Name: props["media.name"],
424+
Name: clientName(props),
410425
App: props["application.name"],
411426
Muted: s.IsMute(),
412427
Volume: s.GetVolume(),
@@ -419,6 +434,18 @@ func (pa *PulseAudioBackend) parsePulseSinkInput(s pulseaudio.SinkInput) AudioCl
419434
}
420435
}
421436

437+
// clientName is a client's routing and display name: media.name, falling back
438+
// to application.name then the process binary for streams that register empty
439+
// names (e.g. spotifyd).
440+
func clientName(props map[string]string) string {
441+
for _, key := range []string{"media.name", "application.name", "application.process.binary"} {
442+
if v := props[key]; v != "" {
443+
return v
444+
}
445+
}
446+
return ""
447+
}
448+
422449
func detectServerKind(s *pulseaudio.Server) AudioServerKind {
423450
if strings.Contains(strings.ToLower(s.PackageName), "pipewire") {
424451
return ServerPipeWire

backend/pulseaudio/pulseaudio_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,54 @@ func TestRemoveMissingClients(t *testing.T) {
652652
})
653653
}
654654
}
655+
656+
// TestClientName verifies the naming fallback chain for streams that register
657+
// empty names (issue #130: spotifyd).
658+
func TestClientName(t *testing.T) {
659+
tests := []struct {
660+
name string
661+
props map[string]string
662+
want string
663+
}{
664+
{
665+
name: "media.name wins",
666+
props: map[string]string{
667+
"media.name": "Playback",
668+
"application.name": "Spotify",
669+
"application.process.binary": "spotify",
670+
},
671+
want: "Playback",
672+
},
673+
{
674+
name: "application.name when media.name empty",
675+
props: map[string]string{
676+
"media.name": "",
677+
"application.name": "Spotify",
678+
"application.process.binary": "spotify",
679+
},
680+
want: "Spotify",
681+
},
682+
{
683+
name: "binary when both names empty",
684+
props: map[string]string{
685+
"media.name": "",
686+
"application.name": "",
687+
"application.process.binary": "spotifyd",
688+
},
689+
want: "spotifyd",
690+
},
691+
{
692+
name: "everything empty",
693+
props: map[string]string{},
694+
want: "",
695+
},
696+
}
697+
698+
for _, tt := range tests {
699+
t.Run(tt.name, func(t *testing.T) {
700+
if got := clientName(tt.props); got != tt.want {
701+
t.Errorf("clientName() = %q, want %q", got, tt.want)
702+
}
703+
})
704+
}
705+
}

0 commit comments

Comments
 (0)