From 0cd53d82f159c3ab1f55f2edc9be6b667da9cb26 Mon Sep 17 00:00:00 2001 From: devgianlu Date: Mon, 25 May 2026 13:00:44 +0200 Subject: [PATCH 1/2] feat: support updating Zeroconf name --- zeroconf/backend.go | 3 +++ zeroconf/backend_avahi.go | 47 ++++++++++++++++++++++++++----------- zeroconf/backend_builtin.go | 16 +++++++++++++ zeroconf/zeroconf.go | 8 +++++++ 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/zeroconf/backend.go b/zeroconf/backend.go index cc8e13a2..4e14f2af 100644 --- a/zeroconf/backend.go +++ b/zeroconf/backend.go @@ -11,6 +11,9 @@ type ServiceRegistrar interface { // txt: TXT record key=value pairs Register(name, serviceType, domain string, port int, txt []string) error + // UpdateName updates teh service instance name. + UpdateName(name string) error + // Shutdown stops advertising the service and releases resources. Shutdown() } diff --git a/zeroconf/backend_avahi.go b/zeroconf/backend_avahi.go index 66cafbd7..11bbec57 100644 --- a/zeroconf/backend_avahi.go +++ b/zeroconf/backend_avahi.go @@ -24,9 +24,14 @@ const ( // 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 + conn *dbus.Conn + version string + + entryGroup dbus.BusObject + serviceType string + domain string + port int + txt []string } // NewAvahiRegistrar creates a new avahi-daemon service registrar. @@ -80,14 +85,16 @@ func (a *AvahiRegistrar) Register(name, serviceType, domain string, port int, tx 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) + if a.entryGroup == nil { + 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) } - 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 { @@ -104,7 +111,7 @@ func (a *AvahiRegistrar) Register(name, serviceType, domain string, port int, tx // 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, + if err := a.entryGroup.Call(avahiEntryGroupIface+".AddService", 0, avahiIfUnspec, // interface avahiProtoUnspec, // protocol uint32(0), // flags @@ -114,20 +121,32 @@ func (a *AvahiRegistrar) Register(name, serviceType, domain string, port int, tx "", // host (empty = use default hostname) uint16(port), // port txtBytes, // TXT records - ).Err - if err != nil { + ).Err; err != nil { return fmt.Errorf("failed to add service: %w", err) } + a.serviceType = serviceType + a.domain = domain + a.port = port + a.txt = txt + // Commit the entry group to publish the service - err = a.entryGroup.Call(avahiEntryGroupIface+".Commit", 0).Err - if err != nil { + if err := a.entryGroup.Call(avahiEntryGroupIface+".Commit", 0).Err; err != nil { return fmt.Errorf("failed to commit entry group: %w", err) } return nil } +// UpdateName updates the advertised instance service name. +func (a *AvahiRegistrar) UpdateName(name string) error { + if a.entryGroup != nil { + _ = a.entryGroup.Call(avahiEntryGroupIface+".Reset", 0).Err + } + + return a.Register(name, a.serviceType, a.domain, a.port, a.txt) +} + // Shutdown removes the service from avahi and releases resources. func (a *AvahiRegistrar) Shutdown() { if a.entryGroup != nil { diff --git a/zeroconf/backend_builtin.go b/zeroconf/backend_builtin.go index b773a42c..22fb6420 100644 --- a/zeroconf/backend_builtin.go +++ b/zeroconf/backend_builtin.go @@ -11,6 +11,11 @@ import ( type BuiltinRegistrar struct { server *zeroconf.Server ifaces []net.Interface + + serviceType string + domain string + port int + txt []string } // NewBuiltinRegistrar creates a new built-in mDNS service registrar. @@ -21,11 +26,22 @@ func NewBuiltinRegistrar(ifaces []net.Interface) *BuiltinRegistrar { // Register publishes the service using the built-in mDNS responder. func (b *BuiltinRegistrar) Register(name, serviceType, domain string, port int, txt []string) error { + b.serviceType = serviceType + b.domain = domain + b.port = port + b.txt = txt + var err error b.server, err = zeroconf.Register(name, serviceType, domain, port, txt, b.ifaces) return err } +func (b *BuiltinRegistrar) UpdateName(name string) error { + // Zeroconf library does not support dynamic updates, so we need to restart the server with the new name. + b.Shutdown() + return b.Register(name, b.serviceType, b.domain, b.port, b.txt) +} + // Shutdown stops the mDNS responder. func (b *BuiltinRegistrar) Shutdown() { if b.server != nil { diff --git a/zeroconf/zeroconf.go b/zeroconf/zeroconf.go index fe47f1e9..3167f597 100644 --- a/zeroconf/zeroconf.go +++ b/zeroconf/zeroconf.go @@ -99,6 +99,14 @@ func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, de return z, nil } +func (z *Zeroconf) SetDeviceName(name string) { + z.deviceName = name + + if err := z.registrar.UpdateName(name); err != nil { + z.log.WithError(err).Errorf("failed updating zeroconf service name to %q", name) + } +} + func (z *Zeroconf) SetCurrentUser(username string) { z.userLock.Lock() z.currentUser = username From b3f4a62cfff3c1b414b283f8da8599faa230c84f Mon Sep 17 00:00:00 2001 From: devgianlu Date: Mon, 25 May 2026 13:01:16 +0200 Subject: [PATCH 2/2] feat: add API call to update device name --- api-spec.yml | 16 ++++++++++++++++ daemon/api_server.go | 22 ++++++++++++++++++++++ daemon/app.go | 25 +++++++++++++++++++------ daemon/player.go | 10 ++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/api-spec.yml b/api-spec.yml index dbee7809..bebcc7dd 100644 --- a/api-spec.yml +++ b/api-spec.yml @@ -109,6 +109,22 @@ paths: type: boolean track: $ref: '#/components/schemas/track' + /set_device_name: + post: + description: Set the player device name + requestBody: + content: + application/json: + schema: + type: object + required: [ name ] + properties: + name: + description: The new device name + type: string + responses: + 200: + description: Successful response /player/play: post: description: Starts playing new content diff --git a/daemon/api_server.go b/daemon/api_server.go index 6576a9a8..d830363e 100644 --- a/daemon/api_server.go +++ b/daemon/api_server.go @@ -74,6 +74,7 @@ const ( ApiRequestTypeSetShufflingContext ApiRequestType = "shuffling_context" ApiRequestTypeAddToQueue ApiRequestType = "add_to_queue" ApiRequestTypeToken ApiRequestType = "token" + ApiRequestSetDeviceName ApiRequestType = "set_device_name" ) type ApiEventType string @@ -648,6 +649,27 @@ func (s *ConcreteApiServer) serve() { s.handleRequest(ApiRequest{Type: ApiRequestTypeToken}, w) }) + m.HandleFunc("/set_device_name", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + var data struct { + Name string `json:"name"` + } + if err := jsonDecode(r, &data); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if len(data.Name) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + s.handleRequest(ApiRequest{Type: ApiRequestSetDeviceName, Data: data.Name}, w) + }) m.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { opts := &websocket.AcceptOptions{} if len(s.allowOrigin) > 0 { diff --git a/daemon/app.go b/daemon/app.go index ca210080..1d76fb70 100644 --- a/daemon/app.go +++ b/daemon/app.go @@ -30,6 +30,7 @@ type App struct { client *http.Client resolver *apresolve.ApResolver + zeroconf *zeroconf.Zeroconf deviceId string deviceType devicespb.DeviceType @@ -129,6 +130,18 @@ func New(opts *Options) (*App, error) { return app, nil } +func (app *App) SetDeviceName(name string) { + if app.cfg.DeviceName == name { + return + } + + app.cfg.DeviceName = name + + if app.zeroconf != nil { + app.zeroconf.SetDeviceName(name) + } +} + // Run starts the daemon. It blocks until ctx is cancelled or an unrecoverable // error occurs. The credential type configured in cfg.Credentials.Type // determines which login flow is used. @@ -306,7 +319,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co return fmt.Errorf("failed getting endpoints from resolver: %w", err) } - z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise, app.cfg.ZeroconfBackend == "avahi") + app.zeroconf, 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) } @@ -325,7 +338,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co apiCh = make(chan ApiRequest) go currentPlayer.Run(ctx, apiCh, app.mpris.Receive()) - z.SetCurrentUser(currentPlayer.sess.Username()) + app.zeroconf.SetCurrentUser(currentPlayer.sess.Username()) } go func() { @@ -363,16 +376,16 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co if err != nil { app.log.WithError(err).Errorf("failed restoring session after logout") - z.SetCurrentUser("") + app.zeroconf.SetCurrentUser("") } else if newAppPlayer == nil { - z.SetCurrentUser("") + app.zeroconf.SetCurrentUser("") } else { apiCh = make(chan ApiRequest) currentPlayer = newAppPlayer go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive()) - z.SetCurrentUser(newAppPlayer.sess.Username()) + app.zeroconf.SetCurrentUser(newAppPlayer.sess.Username()) app.log.WithField("username", librespot.ObfuscateUsername(currentPlayer.sess.Username())). Debugf("restored session after logout") @@ -381,7 +394,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co } }() - return z.Serve(func(req zeroconf.NewUserRequest) bool { + return app.zeroconf.Serve(func(req zeroconf.NewUserRequest) bool { if currentPlayer != nil { currentPlayer.Close() currentPlayer = nil diff --git a/daemon/player.go b/daemon/player.go index 1b228541..c255f831 100644 --- a/daemon/player.go +++ b/daemon/player.go @@ -593,11 +593,21 @@ func (p *AppPlayer) handleApiRequest(ctx context.Context, req ApiRequest) (any, return &ApiResponseToken{ Token: accessToken, }, nil + case ApiRequestSetDeviceName: + p.setDeviceName(ctx, req.Data.(string)) + return nil, nil default: return nil, fmt.Errorf("unknown request type: %s", req.Type) } } +func (p *AppPlayer) setDeviceName(ctx context.Context, name string) { + p.app.SetDeviceName(name) + + p.state.device.Name = name + p.updateState(ctx) +} + func pointer[T any](d T) *T { return &d }