Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
58 changes: 58 additions & 0 deletions zeroconf/discovery/avahi.go
Original file line number Diff line number Diff line change
@@ -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()
}
29 changes: 29 additions & 0 deletions zeroconf/discovery/builtin.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
14 changes: 14 additions & 0 deletions zeroconf/discovery/impl.go
Original file line number Diff line number Diff line change
@@ -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]
}
47 changes: 35 additions & 12 deletions zeroconf/zeroconf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand All @@ -46,16 +47,29 @@ 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()
if err != nil {
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)
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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()
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading