Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
17 changes: 17 additions & 0 deletions zeroconf/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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
}
}
35 changes: 35 additions & 0 deletions zeroconf/backend_builtin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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
Loading