Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,40 @@ The default directory for configuration files is `~/.config/go-librespot`. On ma

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

### Zeroconf mode
### Zeroconf Mode and mDNS Backend Selection

This is the default mode. It uses mDNS auto discovery to allow Spotify clients inside the same network to connect to
go-librespot. This is also known as Spotify Connect.
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.

An example configuration (not required) looks like this:
**Backend selection:**
go-librespot supports two different backends for mDNS service registration:

- **builtin**: (default) Uses the built-in mDNS responder provided by go-librespot itself.
- **avahi**: Uses the system's avahi-daemon (via D-Bus) for mDNS service registration.

You can configure which backend to use via the `zeroconf_backend` setting in your configuration file:

```yaml
zeroconf_backend: avahi # Options: "builtin" (default), "avahi"
```

Or via the command line:

```shell
go-librespot -c zeroconf_backend=avahi
```

#### Which backend should I use?

- 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`).
- Compatible with Avahi 0.6.x and later (tested with 0.7 and 0.8).
- Use **builtin** if you do **not** have Avahi running and want go-librespot to manage its own mDNS advertisements (no extra dependencies required).

#### Example minimal Zeroconf configuration

```yaml
zeroconf_enabled: false # Whether to keep the device discoverable at all times, even if authenticated via other means
zeroconf_port: 0 # The port to use for Zeroconf, 0 for random
zeroconf_enabled: true # Whether to keep the device discoverable at all times, even if authenticated via other means
zeroconf_port: 0 # The port to use for Zeroconf, 0 for random
zeroconf_backend: avahi
credentials:
type: zeroconf
zeroconf:
Expand Down
5 changes: 4 additions & 1 deletion cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co
}

// start zeroconf server and dispatch
z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise)
z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise, app.cfg.ZeroconfBackend == "avahi")
if err != nil {
return fmt.Errorf("failed initializing zeroconf: %w", err)
}
Expand Down Expand Up @@ -410,6 +410,7 @@ type Config struct {
ExternalVolume bool `koanf:"external_volume"`
ZeroconfEnabled bool `koanf:"zeroconf_enabled"`
ZeroconfPort int `koanf:"zeroconf_port"`
ZeroconfBackend string `koanf:"zeroconf_backend"`
DisableAutoplay bool `koanf:"disable_autoplay"`
ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"`
MprisEnabled bool `koanf:"mpris_enabled"`
Expand Down Expand Up @@ -492,6 +493,8 @@ func loadConfig(cfg *Config) error {

"credentials.type": "zeroconf",

"zeroconf_backend": "builtin",

"server.address": "localhost",
"server.image_size": "default",
}, "."), nil)
Expand Down
9 changes: 9 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@
"description": "The port to use for the Zeroconf service (empty for random)",
"default": 0
},
"zeroconf_backend": {
"type": "string",
"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",
"enum": [
"builtin",
"avahi"
],
"default": "builtin"
},
"zeroconf_interfaces_to_advertise": {
"type": "array",
"description": "List of network interfaces that will be advertised through zeroconf (empty to advertise all present interfaces)",
Expand Down
16 changes: 16 additions & 0 deletions zeroconf/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package zeroconf

// ServiceRegistrar handles mDNS service registration.
// Implementations can use different backends like built-in mDNS or avahi-daemon.
type ServiceRegistrar interface {
// Register publishes the service via mDNS.
// name: service instance name (e.g., "go-librespot")
// serviceType: service type (e.g., "_spotify-connect._tcp")
// domain: domain to register in (e.g., "local.")
// port: TCP port the service is listening on
// txt: TXT record key=value pairs
Register(name, serviceType, domain string, port int, txt []string) error

// Shutdown stops advertising the service and releases resources.
Shutdown()
}
142 changes: 142 additions & 0 deletions zeroconf/backend_avahi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package zeroconf

import (
"fmt"

"github.com/godbus/dbus/v5"
)

const (
avahiService = "org.freedesktop.Avahi"
avahiServerPath = "/"
avahiServerIface = "org.freedesktop.Avahi.Server"
avahiEntryGroupIface = "org.freedesktop.Avahi.EntryGroup"

// Avahi constants
avahiIfUnspec = int32(-1) // AVAHI_IF_UNSPEC - use all interfaces
avahiProtoUnspec = int32(-1) // AVAHI_PROTO_UNSPEC - use both IPv4 and IPv6
)

// AvahiRegistrar implements ServiceRegistrar using avahi-daemon via D-Bus.
// This allows go-librespot to share the mDNS responder with other services
// on the system instead of running its own.
//
// Compatibility: Requires avahi-daemon 0.6.x or later (uses stable D-Bus API).
// Tested with avahi 0.7 and 0.8.
type AvahiRegistrar struct {
conn *dbus.Conn
entryGroup dbus.BusObject
version string
}

// NewAvahiRegistrar creates a new avahi-daemon service registrar.
// It connects to the system D-Bus and prepares to register services via avahi.
func NewAvahiRegistrar() (*AvahiRegistrar, error) {
conn, err := dbus.SystemBus()
if err != nil {
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
}

// Verify avahi-daemon is available by calling GetHostName (available in all versions)
server := conn.Object(avahiService, avahiServerPath)
var hostname string
err = server.Call(avahiServerIface+".GetHostName", 0).Store(&hostname)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to connect to avahi-daemon (is it running?): %w", err)
}

// Try to get version for logging (optional, may fail on older versions)
version := getAvahiVersion(server)

return &AvahiRegistrar{conn: conn, version: version}, nil
}

// getAvahiVersion attempts to retrieve the avahi-daemon version.
// Returns "unknown" if version cannot be determined.
func getAvahiVersion(server dbus.BusObject) string {
// Try GetVersionString first (available in avahi 0.8+)
var versionStr string
if err := server.Call(avahiServerIface+".GetVersionString", 0).Store(&versionStr); err == nil {
return versionStr
}

// Try GetAPIVersion (returns a single uint32)
var apiVersion uint32
if err := server.Call(avahiServerIface+".GetAPIVersion", 0).Store(&apiVersion); err == nil {
return fmt.Sprintf("API v%d", apiVersion)
}

return "unknown"
}

// Version returns the avahi-daemon version string.
func (a *AvahiRegistrar) Version() string {
return a.version
}

// Register publishes the service via avahi-daemon.
func (a *AvahiRegistrar) Register(name, serviceType, domain string, port int, txt []string) error {
server := a.conn.Object(avahiService, avahiServerPath)

// Create a new entry group for our service
var groupPath dbus.ObjectPath
err := server.Call(avahiServerIface+".EntryGroupNew", 0).Store(&groupPath)
if err != nil {
return fmt.Errorf("failed to create entry group: %w", err)
}

a.entryGroup = a.conn.Object(avahiService, groupPath)

// Convert TXT records to [][]byte format required by avahi
txtBytes := make([][]byte, len(txt))
for i, t := range txt {
txtBytes[i] = []byte(t)
}

// AddService signature: iiussssqaay
// interface (i): network interface index, -1 for all
// protocol (i): IP protocol, -1 for both IPv4/IPv6
// flags (u): publish flags, 0 for default
// name (s): service instance name
// type (s): service type (e.g., "_spotify-connect._tcp")
// domain (s): domain to publish in (e.g., "local")
// host (s): hostname, empty for default
// port (q): port number (uint16)
// txt (aay): TXT record data as array of byte arrays
err = a.entryGroup.Call(avahiEntryGroupIface+".AddService", 0,
avahiIfUnspec, // interface
avahiProtoUnspec, // protocol
uint32(0), // flags
name, // service name
serviceType, // service type
domain, // domain
"", // host (empty = use default hostname)
uint16(port), // port
txtBytes, // TXT records
).Err
if err != nil {
return fmt.Errorf("failed to add service: %w", err)
}

// Commit the entry group to publish the service
err = a.entryGroup.Call(avahiEntryGroupIface+".Commit", 0).Err
if err != nil {
return fmt.Errorf("failed to commit entry group: %w", err)
}

return nil
}

// Shutdown removes the service from avahi and releases resources.
func (a *AvahiRegistrar) Shutdown() {
if a.entryGroup != nil {
// Free the entry group (this also unpublishes the service)
_ = a.entryGroup.Call(avahiEntryGroupIface+".Free", 0).Err
a.entryGroup = nil
}
if a.conn != nil {
_ = a.conn.Close()
a.conn = nil
}
}
34 changes: 34 additions & 0 deletions zeroconf/backend_builtin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package zeroconf

import (
"net"

"github.com/grandcat/zeroconf"
)

// BuiltinRegistrar implements ServiceRegistrar using the grandcat/zeroconf library,
// which provides a pure-Go mDNS responder.
type BuiltinRegistrar struct {
server *zeroconf.Server
ifaces []net.Interface
}

// NewBuiltinRegistrar creates a new built-in mDNS service registrar.
// If ifaces is empty, the service will be advertised on all interfaces.
func NewBuiltinRegistrar(ifaces []net.Interface) *BuiltinRegistrar {
return &BuiltinRegistrar{ifaces: ifaces}
}

// Register publishes the service using the built-in mDNS responder.
func (b *BuiltinRegistrar) Register(name, serviceType, domain string, port int, txt []string) error {
var err error
b.server, err = zeroconf.Register(name, serviceType, domain, port, txt, b.ifaces)
return err
}

// Shutdown stops the mDNS responder.
func (b *BuiltinRegistrar) Shutdown() {
if b.server != nil {
b.server.Shutdown()
}
}
45 changes: 31 additions & 14 deletions zeroconf/zeroconf.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/dh"
devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices"
"github.com/grandcat/zeroconf"
)

type Zeroconf struct {
Expand All @@ -26,8 +25,8 @@ type Zeroconf struct {
deviceId string
deviceType devicespb.DeviceType

listener net.Listener
server *zeroconf.Server
listener net.Listener
registrar ServiceRegistrar

dh *dh.DiffieHellman

Expand All @@ -46,7 +45,7 @@ type NewUserRequest struct {
result chan bool
}

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

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

var ifaces []net.Interface
for _, ifaceName := range interfacesToAdvertise {
liface, err := net.InterfaceByName(ifaceName)
// Select the mDNS backend based on configuration
if useAvahi {
avahiReg, err := NewAvahiRegistrar()
if err != nil {
return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err)
_ = z.listener.Close()
return nil, fmt.Errorf("failed initializing avahi registrar: %w", err)
}
z.registrar = avahiReg
log.Infof("using avahi-daemon %s for mDNS service registration", avahiReg.Version())
} else {
var ifaces []net.Interface
for _, ifaceName := range interfacesToAdvertise {
liface, err := net.InterfaceByName(ifaceName)
if err != nil {
_ = z.listener.Close()
return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err)
}

ifaces = append(ifaces, *liface)
log.Infof("advertising on network interface %s", ifaceName)
}

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

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

return z, nil
Expand All @@ -91,7 +108,7 @@ func (z *Zeroconf) SetCurrentUser(username string) {
// Close stops the zeroconf responder and HTTP listener,
// but does not close the last opened session.
func (z *Zeroconf) Close() {
z.server.Shutdown()
z.registrar.Shutdown()
_ = z.listener.Close()
}

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

func (z *Zeroconf) Serve(handler HandleNewRequestFunc) error {
defer z.server.Shutdown()
defer z.registrar.Shutdown()

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