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 313fd27..3e05eea 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 gcc musl-dev +RUN apk -U --no-cache add go alsa-lib-dev avahi-dev libogg-dev libvorbis-dev gcc musl-dev WORKDIR /src diff --git a/Dockerfile.build b/Dockerfile.build index 1b0a7b8..8c6fac3 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -26,6 +26,7 @@ ENV PATH="$VCPKG_ROOT:$PATH" ARG TRIPLET COPY vcpkg.json vcpkg-configuration.json ./ COPY vcpkg-triplets ./vcpkg-triplets +COPY vcpkg-ports ./vcpkg-ports RUN --mount=type=cache,target=/build/vcpkg/downloads \ --mount=type=cache,target=/build/vcpkg/buildtrees \ vcpkg install --triplet "$TRIPLET" 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 bf68918..c3319e4 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/vcpkg-configuration.json b/vcpkg-configuration.json index 86c0b28..136b39b 100644 --- a/vcpkg-configuration.json +++ b/vcpkg-configuration.json @@ -2,6 +2,9 @@ "overlay-triplets": [ "./vcpkg-triplets" ], + "overlay-ports": [ + "./vcpkg-ports" + ], "default-registry": { "kind": "git", "baseline": "c59b04f668d20c7dd83f478835e8266a3cc51270", diff --git a/vcpkg-ports/avahi/portfile.cmake b/vcpkg-ports/avahi/portfile.cmake new file mode 100644 index 0000000..23a279c --- /dev/null +++ b/vcpkg-ports/avahi/portfile.cmake @@ -0,0 +1,33 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO avahi/avahi + REF "v${VERSION}" + SHA512 27bba9a551152dfc7e721f326042e7bfce55d227044a6cbaee04d6fb0e3f59c36e159c2b7a4dd42d1c955cdf37cc1c303e91991c08928bbded91d796e9a22abe + HEAD_REF master +) + +vcpkg_configure_make( + SOURCE_PATH "${SOURCE_PATH}" + AUTOCONFIG + COPY_SOURCE + OPTIONS + --with-distro=none + --disable-python + --disable-manpages + --disable-libevent + --disable-gtk + --disable-gtk3 + --disable-mono + --disable-monodoc + --disable-qt4 + --disable-qt5 + --disable-glib + --disable-gobject + --disable-libdaemon + --disable-gdbm +) + +vcpkg_install_make() +vcpkg_fixup_pkgconfig() + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/vcpkg-ports/avahi/vcpkg.json b/vcpkg-ports/avahi/vcpkg.json new file mode 100644 index 0000000..1d3ef27 --- /dev/null +++ b/vcpkg-ports/avahi/vcpkg.json @@ -0,0 +1,14 @@ +{ + "name": "avahi", + "version": "0.8", + "description": "Service Discovery for Linux using mDNS/DNS-SD", + "homepage": "www.avahi.org", + "license": "LGPL-2.1-or-later", + "supports": "linux", + "dependencies": [ + { + "name": "dbus", + "default-features": false + } + ] +} diff --git a/vcpkg-triplets/base-linux.cmake b/vcpkg-triplets/base-linux.cmake index 93f8fcf..fa53a0f 100644 --- a/vcpkg-triplets/base-linux.cmake +++ b/vcpkg-triplets/base-linux.cmake @@ -3,3 +3,7 @@ set(VCPKG_LIBRARY_LINKAGE dynamic) set(VCPKG_CMAKE_SYSTEM_NAME Linux) set(VCPKG_BUILD_TYPE release) + +if(PORT STREQUAL "dbus") + set(VCPKG_CMAKE_CONFIGURE_OPTIONS -DDBUS_SESSION_SOCKET_DIR=/tmp) +endif() diff --git a/vcpkg.json b/vcpkg.json index 9de9c78..57446ac 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,11 @@ { "dependencies": [ "alsa", + "avahi", + { + "name": "dbus", + "default-features": false + }, "libvorbis" ] } 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) {