Skip to content

Commit c70f355

Browse files
authored
feat: add support for mDNS using avahi or built-in backend (#277)
* feat: add support for mDNS service registration using avahi or built-in backend This update introduces a new configuration option for selecting the mDNS backend in the Zeroconf service. Users can now choose between the built-in mDNS responder and the avahi-daemon for service registration. The configuration schema has been updated accordingly, and the necessary backend implementations have been added. * Update README to include background on zeroconf modes * Fix formatting issues for CI
1 parent 6563513 commit c70f355

File tree

7 files changed

+266
-21
lines changed

7 files changed

+266
-21
lines changed

README.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,40 @@ The default directory for configuration files is `~/.config/go-librespot`. On ma
7070

7171
The full configuration schema is available [here](/config_schema.json), only the main options are detailed below.
7272

73-
### Zeroconf mode
73+
### Zeroconf Mode and mDNS Backend Selection
7474

75-
This is the default mode. It uses mDNS auto discovery to allow Spotify clients inside the same network to connect to
76-
go-librespot. This is also known as Spotify Connect.
75+
Zeroconf mode enables mDNS auto discovery, allowing Spotify clients inside the same network to connect to go-librespot. This is also known as Spotify Connect.
7776

78-
An example configuration (not required) looks like this:
77+
**Backend selection:**
78+
go-librespot supports two different backends for mDNS service registration:
79+
80+
- **builtin**: (default) Uses the built-in mDNS responder provided by go-librespot itself.
81+
- **avahi**: Uses the system's avahi-daemon (via D-Bus) for mDNS service registration.
82+
83+
You can configure which backend to use via the `zeroconf_backend` setting in your configuration file:
84+
85+
```yaml
86+
zeroconf_backend: avahi # Options: "builtin" (default), "avahi"
87+
```
88+
89+
Or via the command line:
90+
91+
```shell
92+
go-librespot -c zeroconf_backend=avahi
93+
```
94+
95+
#### Which backend should I use?
96+
97+
- Use **avahi** if you want to integrate with an existing Avahi daemon, e.g. on embedded systems, to avoid port conflicts, or to centralize mDNS advertisements with system service management (e.g., using `systemd`).
98+
- Compatible with Avahi 0.6.x and later (tested with 0.7 and 0.8).
99+
- Use **builtin** if you do **not** have Avahi running and want go-librespot to manage its own mDNS advertisements (no extra dependencies required).
100+
101+
#### Example minimal Zeroconf configuration
79102

80103
```yaml
81-
zeroconf_enabled: false # Whether to keep the device discoverable at all times, even if authenticated via other means
82-
zeroconf_port: 0 # The port to use for Zeroconf, 0 for random
104+
zeroconf_enabled: true # Whether to keep the device discoverable at all times, even if authenticated via other means
105+
zeroconf_port: 0 # The port to use for Zeroconf, 0 for random
106+
zeroconf_backend: avahi
83107
credentials:
84108
type: zeroconf
85109
zeroconf:

cmd/daemon/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co
253253
}
254254

255255
// start zeroconf server and dispatch
256-
z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise)
256+
z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise, app.cfg.ZeroconfBackend == "avahi")
257257
if err != nil {
258258
return fmt.Errorf("failed initializing zeroconf: %w", err)
259259
}
@@ -410,6 +410,7 @@ type Config struct {
410410
ExternalVolume bool `koanf:"external_volume"`
411411
ZeroconfEnabled bool `koanf:"zeroconf_enabled"`
412412
ZeroconfPort int `koanf:"zeroconf_port"`
413+
ZeroconfBackend string `koanf:"zeroconf_backend"`
413414
DisableAutoplay bool `koanf:"disable_autoplay"`
414415
ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"`
415416
MprisEnabled bool `koanf:"mpris_enabled"`
@@ -496,6 +497,8 @@ func loadConfig(cfg *Config) error {
496497

497498
"credentials.type": "zeroconf",
498499

500+
"zeroconf_backend": "builtin",
501+
499502
"server.address": "localhost",
500503
"server.image_size": "default",
501504
}, "."), nil)

config_schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@
170170
"description": "The port to use for the Zeroconf service (empty for random)",
171171
"default": 0
172172
},
173+
"zeroconf_backend": {
174+
"type": "string",
175+
"description": "The mDNS backend to use for Zeroconf service registration. 'builtin' uses the built-in mDNS responder, 'avahi' uses an existing avahi-daemon via D-Bus",
176+
"enum": [
177+
"builtin",
178+
"avahi"
179+
],
180+
"default": "builtin"
181+
},
173182
"zeroconf_interfaces_to_advertise": {
174183
"type": "array",
175184
"description": "List of network interfaces that will be advertised through zeroconf (empty to advertise all present interfaces)",

zeroconf/backend.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package zeroconf
2+
3+
// ServiceRegistrar handles mDNS service registration.
4+
// Implementations can use different backends like built-in mDNS or avahi-daemon.
5+
type ServiceRegistrar interface {
6+
// Register publishes the service via mDNS.
7+
// name: service instance name (e.g., "go-librespot")
8+
// serviceType: service type (e.g., "_spotify-connect._tcp")
9+
// domain: domain to register in (e.g., "local.")
10+
// port: TCP port the service is listening on
11+
// txt: TXT record key=value pairs
12+
Register(name, serviceType, domain string, port int, txt []string) error
13+
14+
// Shutdown stops advertising the service and releases resources.
15+
Shutdown()
16+
}

zeroconf/backend_avahi.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package zeroconf
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/godbus/dbus/v5"
7+
)
8+
9+
const (
10+
avahiService = "org.freedesktop.Avahi"
11+
avahiServerPath = "/"
12+
avahiServerIface = "org.freedesktop.Avahi.Server"
13+
avahiEntryGroupIface = "org.freedesktop.Avahi.EntryGroup"
14+
15+
// Avahi constants
16+
avahiIfUnspec = int32(-1) // AVAHI_IF_UNSPEC - use all interfaces
17+
avahiProtoUnspec = int32(-1) // AVAHI_PROTO_UNSPEC - use both IPv4 and IPv6
18+
)
19+
20+
// AvahiRegistrar implements ServiceRegistrar using avahi-daemon via D-Bus.
21+
// This allows go-librespot to share the mDNS responder with other services
22+
// on the system instead of running its own.
23+
//
24+
// Compatibility: Requires avahi-daemon 0.6.x or later (uses stable D-Bus API).
25+
// Tested with avahi 0.7 and 0.8.
26+
type AvahiRegistrar struct {
27+
conn *dbus.Conn
28+
entryGroup dbus.BusObject
29+
version string
30+
}
31+
32+
// NewAvahiRegistrar creates a new avahi-daemon service registrar.
33+
// It connects to the system D-Bus and prepares to register services via avahi.
34+
func NewAvahiRegistrar() (*AvahiRegistrar, error) {
35+
conn, err := dbus.SystemBus()
36+
if err != nil {
37+
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
38+
}
39+
40+
// Verify avahi-daemon is available by calling GetHostName (available in all versions)
41+
server := conn.Object(avahiService, avahiServerPath)
42+
var hostname string
43+
err = server.Call(avahiServerIface+".GetHostName", 0).Store(&hostname)
44+
if err != nil {
45+
conn.Close()
46+
return nil, fmt.Errorf("failed to connect to avahi-daemon (is it running?): %w", err)
47+
}
48+
49+
// Try to get version for logging (optional, may fail on older versions)
50+
version := getAvahiVersion(server)
51+
52+
return &AvahiRegistrar{conn: conn, version: version}, nil
53+
}
54+
55+
// getAvahiVersion attempts to retrieve the avahi-daemon version.
56+
// Returns "unknown" if version cannot be determined.
57+
func getAvahiVersion(server dbus.BusObject) string {
58+
// Try GetVersionString first (available in avahi 0.8+)
59+
var versionStr string
60+
if err := server.Call(avahiServerIface+".GetVersionString", 0).Store(&versionStr); err == nil {
61+
return versionStr
62+
}
63+
64+
// Try GetAPIVersion (returns a single uint32)
65+
var apiVersion uint32
66+
if err := server.Call(avahiServerIface+".GetAPIVersion", 0).Store(&apiVersion); err == nil {
67+
return fmt.Sprintf("API v%d", apiVersion)
68+
}
69+
70+
return "unknown"
71+
}
72+
73+
// Version returns the avahi-daemon version string.
74+
func (a *AvahiRegistrar) Version() string {
75+
return a.version
76+
}
77+
78+
// Register publishes the service via avahi-daemon.
79+
func (a *AvahiRegistrar) Register(name, serviceType, domain string, port int, txt []string) error {
80+
server := a.conn.Object(avahiService, avahiServerPath)
81+
82+
// Create a new entry group for our service
83+
var groupPath dbus.ObjectPath
84+
err := server.Call(avahiServerIface+".EntryGroupNew", 0).Store(&groupPath)
85+
if err != nil {
86+
return fmt.Errorf("failed to create entry group: %w", err)
87+
}
88+
89+
a.entryGroup = a.conn.Object(avahiService, groupPath)
90+
91+
// Convert TXT records to [][]byte format required by avahi
92+
txtBytes := make([][]byte, len(txt))
93+
for i, t := range txt {
94+
txtBytes[i] = []byte(t)
95+
}
96+
97+
// AddService signature: iiussssqaay
98+
// interface (i): network interface index, -1 for all
99+
// protocol (i): IP protocol, -1 for both IPv4/IPv6
100+
// flags (u): publish flags, 0 for default
101+
// name (s): service instance name
102+
// type (s): service type (e.g., "_spotify-connect._tcp")
103+
// domain (s): domain to publish in (e.g., "local")
104+
// host (s): hostname, empty for default
105+
// port (q): port number (uint16)
106+
// txt (aay): TXT record data as array of byte arrays
107+
err = a.entryGroup.Call(avahiEntryGroupIface+".AddService", 0,
108+
avahiIfUnspec, // interface
109+
avahiProtoUnspec, // protocol
110+
uint32(0), // flags
111+
name, // service name
112+
serviceType, // service type
113+
domain, // domain
114+
"", // host (empty = use default hostname)
115+
uint16(port), // port
116+
txtBytes, // TXT records
117+
).Err
118+
if err != nil {
119+
return fmt.Errorf("failed to add service: %w", err)
120+
}
121+
122+
// Commit the entry group to publish the service
123+
err = a.entryGroup.Call(avahiEntryGroupIface+".Commit", 0).Err
124+
if err != nil {
125+
return fmt.Errorf("failed to commit entry group: %w", err)
126+
}
127+
128+
return nil
129+
}
130+
131+
// Shutdown removes the service from avahi and releases resources.
132+
func (a *AvahiRegistrar) Shutdown() {
133+
if a.entryGroup != nil {
134+
// Free the entry group (this also unpublishes the service)
135+
_ = a.entryGroup.Call(avahiEntryGroupIface+".Free", 0).Err
136+
a.entryGroup = nil
137+
}
138+
if a.conn != nil {
139+
_ = a.conn.Close()
140+
a.conn = nil
141+
}
142+
}

zeroconf/backend_builtin.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package zeroconf
2+
3+
import (
4+
"net"
5+
6+
"github.com/grandcat/zeroconf"
7+
)
8+
9+
// BuiltinRegistrar implements ServiceRegistrar using the grandcat/zeroconf library,
10+
// which provides a pure-Go mDNS responder.
11+
type BuiltinRegistrar struct {
12+
server *zeroconf.Server
13+
ifaces []net.Interface
14+
}
15+
16+
// NewBuiltinRegistrar creates a new built-in mDNS service registrar.
17+
// If ifaces is empty, the service will be advertised on all interfaces.
18+
func NewBuiltinRegistrar(ifaces []net.Interface) *BuiltinRegistrar {
19+
return &BuiltinRegistrar{ifaces: ifaces}
20+
}
21+
22+
// Register publishes the service using the built-in mDNS responder.
23+
func (b *BuiltinRegistrar) Register(name, serviceType, domain string, port int, txt []string) error {
24+
var err error
25+
b.server, err = zeroconf.Register(name, serviceType, domain, port, txt, b.ifaces)
26+
return err
27+
}
28+
29+
// Shutdown stops the mDNS responder.
30+
func (b *BuiltinRegistrar) Shutdown() {
31+
if b.server != nil {
32+
b.server.Shutdown()
33+
}
34+
}

zeroconf/zeroconf.go

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
librespot "github.com/devgianlu/go-librespot"
1717
"github.com/devgianlu/go-librespot/dh"
1818
devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices"
19-
"github.com/grandcat/zeroconf"
2019
)
2120

2221
type Zeroconf struct {
@@ -26,8 +25,8 @@ type Zeroconf struct {
2625
deviceId string
2726
deviceType devicespb.DeviceType
2827

29-
listener net.Listener
30-
server *zeroconf.Server
28+
listener net.Listener
29+
registrar ServiceRegistrar
3130

3231
dh *dh.DiffieHellman
3332

@@ -46,7 +45,7 @@ type NewUserRequest struct {
4645
result chan bool
4746
}
4847

49-
func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, deviceType devicespb.DeviceType, interfacesToAdvertise []string) (_ *Zeroconf, err error) {
48+
func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, deviceType devicespb.DeviceType, interfacesToAdvertise []string, useAvahi bool) (_ *Zeroconf, err error) {
5049
z := &Zeroconf{log: log, deviceId: deviceId, deviceName: deviceName, deviceType: deviceType}
5150
z.reqsChan = make(chan NewUserRequest)
5251

@@ -63,20 +62,38 @@ func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, de
6362
listenPort := z.listener.Addr().(*net.TCPAddr).Port
6463
log.Infof("zeroconf server listening on port %d", listenPort)
6564

66-
var ifaces []net.Interface
67-
for _, ifaceName := range interfacesToAdvertise {
68-
liface, err := net.InterfaceByName(ifaceName)
65+
// Select the mDNS backend based on configuration
66+
if useAvahi {
67+
avahiReg, err := NewAvahiRegistrar()
6968
if err != nil {
70-
return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err)
69+
_ = z.listener.Close()
70+
return nil, fmt.Errorf("failed initializing avahi registrar: %w", err)
71+
}
72+
z.registrar = avahiReg
73+
log.Infof("using avahi-daemon %s for mDNS service registration", avahiReg.Version())
74+
} else {
75+
var ifaces []net.Interface
76+
for _, ifaceName := range interfacesToAdvertise {
77+
liface, err := net.InterfaceByName(ifaceName)
78+
if err != nil {
79+
_ = z.listener.Close()
80+
return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err)
81+
}
82+
83+
ifaces = append(ifaces, *liface)
84+
log.Infof("advertising on network interface %s", ifaceName)
7185
}
7286

73-
ifaces = append(ifaces, *liface)
74-
log.Info(fmt.Sprintf("advertising on network interface %s", ifaceName))
87+
z.registrar = NewBuiltinRegistrar(ifaces)
88+
log.Infof("using built-in mDNS responder")
7589
}
7690

77-
z.server, err = zeroconf.Register(deviceName, "_spotify-connect._tcp", "local.", listenPort, []string{"CPath=/", "VERSION=1.0", "Stack=SP"}, ifaces)
91+
// Register the Spotify Connect service
92+
err = z.registrar.Register(deviceName, "_spotify-connect._tcp", "local.", listenPort, []string{"CPath=/", "VERSION=1.0", "Stack=SP"})
7893
if err != nil {
79-
return nil, fmt.Errorf("failed registering zeroconf server: %w", err)
94+
z.registrar.Shutdown()
95+
_ = z.listener.Close()
96+
return nil, fmt.Errorf("failed registering zeroconf service: %w", err)
8097
}
8198

8299
return z, nil
@@ -91,7 +108,7 @@ func (z *Zeroconf) SetCurrentUser(username string) {
91108
// Close stops the zeroconf responder and HTTP listener,
92109
// but does not close the last opened session.
93110
func (z *Zeroconf) Close() {
94-
z.server.Shutdown()
111+
z.registrar.Shutdown()
95112
_ = z.listener.Close()
96113
}
97114

@@ -246,7 +263,7 @@ func (z *Zeroconf) handleAddUser(writer http.ResponseWriter, request *http.Reque
246263
type HandleNewRequestFunc func(req NewUserRequest) bool
247264

248265
func (z *Zeroconf) Serve(handler HandleNewRequestFunc) error {
249-
defer z.server.Shutdown()
266+
defer z.registrar.Shutdown()
250267

251268
mux := http.NewServeMux()
252269
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {

0 commit comments

Comments
 (0)