diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index da37752..5b3ecb2 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -22,7 +22,7 @@ jobs: - name: Install Linux dependencies if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libasound2-dev libvorbis-dev libogg-dev + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libvorbis-dev libogg-dev libavahi-client-dev - name: Install macOS dependencies if: runner.os == 'macOS' diff --git a/Dockerfile b/Dockerfile index d95f8b6..45feaae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM alpine:3.20 AS build -RUN apk -U --no-cache add go alsa-lib-dev libogg-dev libvorbis-dev +RUN apk -U --no-cache add go alsa-lib-dev avahi-dev libogg-dev libvorbis-dev WORKDIR /src diff --git a/README.md b/README.md index ebee505..816098f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ An example configuration (not required) looks like this: ```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_implementation: builtin # Zeroconf implementation to use (builtin, avahi) credentials: type: zeroconf zeroconf: @@ -93,6 +94,9 @@ If `zeroconf_interfaces_to_advertise` is provided, you can limit interfaces that have Docker installed on your host, you may want to disable advertising to its bridge interface, or you may want to disable interfaces that will not be reachable. +If `zeroconf_implementation` is set to `avahi`, go-librespot will use Avahi through D-Bus to advertise the service. This +is preferred if you already have Avahi running on your system. The default implementation may conflict with Avahi. + ### Interactive mode This mode allows you to associate your account with the device and make it discoverable even outside the network. It diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 2f7ba53..007a758 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -244,7 +244,18 @@ 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(zeroconf.Options{ + Log: app.log, + + Port: app.cfg.ZeroconfPort, + + DeviceName: app.cfg.DeviceName, + DeviceId: app.deviceId, + DeviceType: app.deviceType, + + DiscoveryImplementation: app.cfg.ZeroconfImplementation, + InterfacesToAdvertise: app.cfg.ZeroconfInterfacesToAdvertise, + }) if err != nil { return fmt.Errorf("failed initializing zeroconf: %w", err) } @@ -399,6 +410,7 @@ type Config struct { ZeroconfEnabled bool `koanf:"zeroconf_enabled"` ZeroconfPort int `koanf:"zeroconf_port"` DisableAutoplay bool `koanf:"disable_autoplay"` + ZeroconfImplementation string `koanf:"zeroconf_implementation"` ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"` MprisEnabled bool `koanf:"mpris_enabled"` Server struct { diff --git a/config_schema.json b/config_schema.json index 0bc7b2f..6a302f8 100644 --- a/config_schema.json +++ b/config_schema.json @@ -165,6 +165,15 @@ "description": "List of network interfaces that will be advertised through zeroconf (empty to advertise all present interfaces)", "default": [] }, + "zeroconf_implementation": { + "type": "string", + "description": "The Zeroconf implementation to use", + "enum": [ + "builtin", + "avahi" + ], + "default": "builtin" + }, "credentials": { "type": "object", "properties": { diff --git a/go.mod b/go.mod index 2c2ac76..a4540a8 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/devgianlu/go-librespot go 1.22.2 require ( + github.com/OpenPrinting/go-avahi v0.0.0-20250813163007-dd9db1c4a6e9 github.com/cenkalti/backoff/v4 v4.2.1 github.com/devgianlu/shannon v0.0.0-20230613115856-82ec90b7fa7e github.com/godbus/dbus/v5 v5.1.0 diff --git a/go.sum b/go.sum index 39e79da..952a9e9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/OpenPrinting/go-avahi v0.0.0-20250813163007-dd9db1c4a6e9 h1:sYwgzNSkvqBnhfhS5THhHdSYt+8aleQBGMLObOOg5vM= +github.com/OpenPrinting/go-avahi v0.0.0-20250813163007-dd9db1c4a6e9/go.mod h1:1vYkalHi1N1ZQ+Wt7KX7WK8fTS09iwrZnuQjwpsUMCU= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= diff --git a/zeroconf/discovery/avahi.go b/zeroconf/discovery/avahi.go new file mode 100644 index 0000000..c798090 --- /dev/null +++ b/zeroconf/discovery/avahi.go @@ -0,0 +1,58 @@ +//go:build linux + +package discovery + +import ( + "fmt" + "net" + + "github.com/OpenPrinting/go-avahi" +) + +func init() { + discoveryServices["avahi"] = &avahiDiscoveryService{} +} + +type avahiDiscoveryService struct { + client *avahi.Client + group *avahi.EntryGroup +} + +func (s *avahiDiscoveryService) Register(name, service, domain string, port int, txt []string, ifaces []net.Interface) (err error) { + if len(ifaces) > 0 { + return fmt.Errorf("avahi discovery does not support specifying interfaces") + } + + s.client, err = avahi.NewClient(avahi.ClientLoopbackWorkarounds) + if err != nil { + return fmt.Errorf("failed to create Avahi client: %w", err) + } + + s.group, err = avahi.NewEntryGroup(s.client) + if err != nil { + return fmt.Errorf("failed to create Avahi entry group: %w", err) + } + + if err = s.group.AddService(&avahi.EntryGroupService{ + IfIdx: avahi.IfIndexUnspec, + Proto: avahi.ProtocolUnspec, + InstanceName: name, + SvcType: service, + Domain: domain, + Port: port, + Txt: txt, + }, 0); err != nil { + return fmt.Errorf("failed to add service to Avahi entry group: %w", err) + } + + if err = s.group.Commit(); err != nil { + return fmt.Errorf("failed to commit Avahi entry group: %w", err) + } + + return nil +} + +func (s *avahiDiscoveryService) Shutdown() { + s.group.Close() + s.client.Close() +} diff --git a/zeroconf/discovery/builtin.go b/zeroconf/discovery/builtin.go new file mode 100644 index 0000000..8c03bd4 --- /dev/null +++ b/zeroconf/discovery/builtin.go @@ -0,0 +1,29 @@ +package discovery + +import ( + "net" + + "github.com/grandcat/zeroconf" +) + +func init() { + discoveryServices["builtin"] = &builtinDiscoveryService{} +} + +type builtinDiscoveryService struct { + server *zeroconf.Server +} + +func (s *builtinDiscoveryService) Register(name, service, domain string, port int, txt []string, ifaces []net.Interface) (err error) { + s.server, err = zeroconf.Register(name, service, domain, port, txt, ifaces) + if err != nil { + return err + } + return nil +} + +func (s *builtinDiscoveryService) Shutdown() { + if s.server != nil { + s.server.Shutdown() + } +} diff --git a/zeroconf/discovery/impl.go b/zeroconf/discovery/impl.go new file mode 100644 index 0000000..4031145 --- /dev/null +++ b/zeroconf/discovery/impl.go @@ -0,0 +1,14 @@ +package discovery + +import "net" + +type Service interface { + Register(name, service, domain string, port int, txt []string, ifaces []net.Interface) error + Shutdown() +} + +var discoveryServices = map[string]Service{} + +func GetService(name string) Service { + return discoveryServices[name] +} diff --git a/zeroconf/zeroconf.go b/zeroconf/zeroconf.go index 408476e..28fce54 100644 --- a/zeroconf/zeroconf.go +++ b/zeroconf/zeroconf.go @@ -16,7 +16,8 @@ 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" + "github.com/devgianlu/go-librespot/zeroconf/discovery" + log "github.com/sirupsen/logrus" ) type Zeroconf struct { @@ -26,8 +27,8 @@ type Zeroconf struct { deviceId string deviceType devicespb.DeviceType - listener net.Listener - server *zeroconf.Server + listener net.Listener + discovery discovery.Service dh *dh.DiffieHellman @@ -46,8 +47,21 @@ type NewUserRequest struct { result chan bool } -func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, deviceType devicespb.DeviceType, interfacesToAdvertise []string) (_ *Zeroconf, err error) { - z := &Zeroconf{log: log, deviceId: deviceId, deviceName: deviceName, deviceType: deviceType} +type Options struct { + Log librespot.Logger + + Port int + + DeviceName string + DeviceId string + DeviceType devicespb.DeviceType + + DiscoveryImplementation string + InterfacesToAdvertise []string +} + +func NewZeroconf(opts Options) (_ *Zeroconf, err error) { + z := &Zeroconf{log: opts.Log, deviceId: opts.DeviceId, deviceName: opts.DeviceName, deviceType: opts.DeviceType} z.reqsChan = make(chan NewUserRequest) z.dh, err = dh.NewDiffieHellman() @@ -55,7 +69,7 @@ func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, de return nil, fmt.Errorf("failed initializing diffiehellman: %w", err) } - z.listener, err = net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port)) + z.listener, err = net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", opts.Port)) if err != nil { return nil, fmt.Errorf("failed starting zeroconf listener: %w", err) } @@ -64,7 +78,7 @@ func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, de log.Infof("zeroconf server listening on port %d", listenPort) var ifaces []net.Interface - for _, ifaceName := range interfacesToAdvertise { + for _, ifaceName := range opts.InterfacesToAdvertise { liface, err := net.InterfaceByName(ifaceName) if err != nil { return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err) @@ -74,9 +88,18 @@ func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, de log.Info(fmt.Sprintf("advertising on network interface %s", ifaceName)) } - z.server, err = zeroconf.Register(deviceName, "_spotify-connect._tcp", "local.", listenPort, []string{"CPath=/", "VERSION=1.0", "Stack=SP"}, ifaces) - if err != nil { - return nil, fmt.Errorf("failed registering zeroconf server: %w", err) + discoveryImpl := opts.DiscoveryImplementation + if discoveryImpl == "" { + discoveryImpl = "builtin" + } + + z.discovery = discovery.GetService(discoveryImpl) + if z.discovery == nil { + return nil, fmt.Errorf("unknown discovery implementation: %s", discoveryImpl) + } + + if err := z.discovery.Register(z.deviceName, "_spotify-connect._tcp", "local.", listenPort, []string{"CPath=/", "VERSION=1.0", "Stack=SP"}, ifaces); err != nil { + return nil, fmt.Errorf("failed registering zeroconf service: %w", err) } return z, nil @@ -91,7 +114,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.discovery.Shutdown() _ = z.listener.Close() } @@ -246,7 +269,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.discovery.Shutdown() mux := http.NewServeMux() mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {