package handlers import ( "bytes" "context" "crypto/x509" "encoding/pem" "errors" "fmt" "log" "net" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/amazon" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/health" "github.com/gesellix/bose-soundtouch/pkg/service/logbuf" "github.com/gesellix/bose-soundtouch/pkg/service/marge" "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" "github.com/gesellix/bose-soundtouch/pkg/service/tts" "github.com/gesellix/bose-soundtouch/pkg/service/updatecheck" "github.com/gesellix/bose-soundtouch/pkg/ssh" "github.com/miekg/dns" ) // Server handles HTTP requests for the SoundTouch service. type Server struct { ds *datastore.DataStore sm *setup.Manager mu sync.RWMutex serverURL string httpsServerURL string // effective (derived or overridden) HTTPS URL httpsOverride string // explicit HTTPS URL override; "" means derive from serverURL httpsPort string // configured HTTPS port, used when deriving httpsDefaultURL string // startup hostname-based fallback when serverURL has no host httpsListenAddr string discovering bool redactLogs bool logBodies bool recordEnabled bool discoveryInterval time.Duration discoveryEnabled bool updateCheckInterval time.Duration // live update-check interval; see SetUpdateCheckSettings updateCheckEnabled bool // live update-check opt-in; defaults off (#591) dnsEnabled bool dnsUpstream []string dnsBindAddr string internalPaths []string shortcuts map[string]int recorder *proxy.Recorder dnsDiscovery *discovery.DNSDiscovery authProbes *authProbeRegistry authProbeTimeoutOverride time.Duration // zero means use defaultAuthProbeTimeout; injectable for tests deprecatedRoutes *deprecatedRouteTracker devicesChangedHook func() Version string Commit string Date string RepoURL string mgmtUsername string mgmtPassword string adminAreaAuth string // "" (unset) / "enabled" / "disabled" — see datastore.Settings.AdminAreaAuth dismissedAnnouncements map[string]time.Time // announcement id -> most recent dismissal; see RecordDismissal updateChecker *updatecheck.Checker // the HTTP-checking object; nil unless SetUpdateChecker was called spotifyClientID string spotifyClientSecret string spotifyRedirectURI string spotifyService *spotify.Service amazonClientID string amazonClientSecret string amazonRedirectURI string amazonService *amazon.Service ttsService *tts.Service ttsProvider string ttsGoogleAPIKey string ttsGoogleEndpoint string // test-only override; not exposed in the UI ttsAppKey string ttsLanguage string ttsVoice string ttsVolume int peerObserver *peerObserver healthRegistry *health.Registry logBuf *logbuf.Buffer expectedHosts []string ownCACache struct { once sync.Once cert *x509.Certificate } } // RequestSnapshot represents an immutable snapshot of an HTTP request. type RequestSnapshot struct { Method string URL *url.URL Headers http.Header Body []byte Host string Timestamp time.Time } type ctxKey struct{ name string } // SnapshotKey is the context key for the RequestSnapshot. var SnapshotKey = &ctxKey{"request_snapshot"} var bufferPool = sync.Pool{ New: func() interface{} { return new(bytes.Buffer) }, } // NormalizeServerURL trims surrounding whitespace and any trailing slashes from // a configured server URL. A trailing slash poisons every URL built by string // concatenation from it, most visibly the BMX registry base ("{BMX_SERVER}/bmx/ // tunein" in bmx_services.json): it would otherwise hand a speaker // "http://host:8000//bmx/tunein" and make it request "//bmx/tunein/...", a path // the chi router does not match, so playback 404s. It also keeps {MEDIA_SERVER} // and the OAuth redirect URIs free of a stray double slash. func NormalizeServerURL(serverURL string) string { return strings.TrimRight(strings.TrimSpace(serverURL), "/") } // NewServer creates a new SoundTouch service server. func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, redactLogs, logBodies, recordEnabled bool) *Server { s := &Server{ ds: ds, sm: sm, serverURL: NormalizeServerURL(serverURL), redactLogs: redactLogs, logBodies: logBodies, recordEnabled: recordEnabled, discoveryInterval: 5 * time.Minute, discoveryEnabled: true, // The update check is opt-in (#591): only the interval gets a default, // updateCheckEnabled stays false so no install starts making outbound // GitHub calls without an explicit yes. updateCheckInterval: 24 * time.Hour, peerObserver: newPeerObserver(), healthRegistry: health.NewRegistry(), authProbes: newAuthProbeRegistry(defaultAuthProbeTTL), deprecatedRoutes: newDeprecatedRouteTracker(), } health.RegisterSourcesXMLPresent(s.healthRegistry, ds) health.RegisterSpeakerInfoReachable(s.healthRegistry, ds) health.RegisterSourcesXMLDiff(s.healthRegistry, ds) health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts) health.RegisterRuntimeBmxURLStaleCheck( s.healthRegistry, ds, s.readSpeakerBmxRegistryURL, func() bool { running, _ := s.GetDNSRunning() return running }, ) health.RegisterCertChainCheck( s.healthRegistry, func() string { _, httpsURL := s.GetSettings() return httpsURL }, s.actualHTTPSPort, s.loadOwnCACert, ) health.RegisterCACertExpiryCheck(s.healthRegistry, s.loadOwnCACert, s.ownCACertPath) health.RegisterTestPlaybackCheck(s.healthRegistry, ds, func() string { serverURL, _ := s.GetSettings() return serverURL }) health.RegisterOrionPathsCheck(s.healthRegistry, ds) health.RegisterPresetsCountCheck(s.healthRegistry, ds) health.RegisterPresetsConsistencyCheck(s.healthRegistry, ds) health.RegisterRefreshSourcesCheck(s.healthRegistry, ds) health.RegisterStaleInternetRadioCheck(s.healthRegistry, ds) health.RegisterDefaultAccountNonBoseDevicesCheck(s.healthRegistry, ds) health.RegisterSpeakerCABundleCheck(s.healthRegistry, ds, func(deviceIP string) (string, string, bool) { return s.sm.ProbeCABundles(deviceIP) }) health.RegisterSpeakerClockCheck(s.healthRegistry, ds, func(ip string) (int64, int64, bool) { cfg := client.DefaultConfig() cfg.Host = ip cfg.Timeout = 5 * time.Second c := client.NewClient(cfg) ct, err := c.GetClockTime() if err != nil || ct == nil || ct.GetUTC() == 0 { return 0, 0, false } return ct.GetUTC(), ct.GetUTCSyncTime(), true }, s.setSpeakerClock) health.RegisterServerURLReachableCheck(s.healthRegistry, func() string { serverURL, _ := s.GetSettings() return serverURL }) health.RegisterOAuthTargetReachableCheck( s.healthRegistry, func() string { serverURL, _ := s.GetSettings() return serverURL }, s.GetDNSRunning, ) health.RegisterSpotifyAccountLinkedCheck( s.healthRegistry, func() bool { return s.spotifyService != nil }, func() int { if s.spotifyService == nil { return 0 } return len(s.spotifyService.GetAccounts()) }, ) health.RegisterMgmtDefaultCredentialsCheck( s.healthRegistry, func() (string, string) { return s.mgmtUsername, s.mgmtPassword }, ) health.RegisterAdminAreaAuthCheck(s.healthRegistry, s.AdminAreaAuthMode) // Health QuickFix executor for the empty-margeAccountUUID // finding from RegisterSpeakerInfoReachable. Lives here (not in // the health package) because the executor needs setup.Manager // to drive PairAccount — and the health package deliberately // avoids importing setup to keep its transitive dep surface // small (see the boundary comment near speakerInfoXML). s.healthRegistry.RegisterFix( health.CheckIDSpeakerInfoReachable, health.FixIDCompleteSpeakerPairing, s.completeSpeakerPairingFix, ) // QuickFix executor for the speaker_marge_url mismatch finding. // Adds the speaker's actual margeURL host to settings.TLSExtraHosts // so a subsequent restart picks it up via applyPersistedSettings. s.healthRegistry.RegisterFix( health.CheckIDSpeakerMargeURL, health.FixIDAddMargeHostToTLS, s.addMargeHostToTLSFix, ) // QuickFix executors for the speaker_ca_bundle integrity check. // Fix executors live here (not in the health package) because they // need setup.Manager — the same boundary as completeSpeakerPairingFix. s.healthRegistry.RegisterFix( health.CheckIDSpeakerCABundle, health.FixIDRestoreAndInjectCA, s.restoreAndInjectCAFix, ) s.healthRegistry.RegisterFix( health.CheckIDSpeakerCABundle, health.FixIDInjectCACert, s.injectCACertFix, ) health.RegisterDNSSanityCheck( s.healthRegistry, s.GetDNSRunning, func() string { serverURL, _ := s.GetSettings() ip, err := s.ResolveServerURLIPForPreflight(serverURL) if err != nil { return "" } return ip }, ) health.RegisterDNSSpeakerUsageCheck( s.healthRegistry, s.ds, s.GetDNSRunning, func() map[string]time.Time { if s.dnsDiscovery == nil { return map[string]time.Time{} } return s.dnsDiscovery.InterceptClientIPs() }, ) // QuickFix executor for the dns_speaker_usage per-device info findings. // Lives here (not in the health package) because it needs runDNSPathProbe, // which is part of the handlers layer. The health package deliberately // avoids importing handlers to keep its transitive dep surface small. // // Registered without refresh: this probe is a diagnostic whose value is the // result message ("DNS path OK" / "no callback ..."). A refresh would re-fetch // the whole health list and wipe that message from the UI before the operator // can read it. The operator can refresh manually to see a now-confirmed // speaker drop its finding. s.healthRegistry.RegisterFixNoRefresh( health.CheckIDDNSSpeakerUsage, "probe_dns_path", func(target health.Target) (string, error) { res, err := s.runDNSPathProbe(target.Device, "") if err != nil { return "", err } if res.Success { return fmt.Sprintf( "Speaker resolved a Bose hostname through AfterTouch in %.0fms. DNS path OK.", res.LatencyMs, ), nil } msg := "No /v1/auth callback within the timeout; this speaker likely resolves Bose hostnames via a different DNS resolver." if res.Remediation != "" { msg += " " + res.Remediation } return msg, nil }, ) s.dismissedAnnouncements = loadDismissedAnnouncements(ds) return s } // clockSetTolerance is how close the speaker's clock must be to the target // after a set for the set to count as successful. const clockSetTolerance = 2 * time.Minute // setSpeakerClock is the set_clock QuickFix executor. It sets the speaker's // clock to the service's current time and verifies the clock actually moved // before reporting success. // // Two transports are tried because firmware varies: some builds honour // POST /clockTime, but others dispatch that POST to their read handler // (HandleClockGetTime) and silently ignore it, so the HTTP path is a no-op // there. SSH `date` reliably sets the system clock on an SSH-reachable // speaker (root, empty password — the usual unlocked state). We verify by // re-reading /clockTime regardless of which path "succeeded", so we never // report success when the clock didn't change. func (s *Server) setSpeakerClock(ip string) error { now := time.Now() // 1) HTTP /clockTime: harmless, and works on firmware that honours it. httpErr := s.setSpeakerClockHTTP(ip, now) if speakerClockWithin(ip, now, clockSetTolerance) { return nil } // 2) SSH `date`: the reliable path on firmware that ignores the HTTP POST. sshErr := setSpeakerClockSSH(ip, now) if speakerClockWithin(ip, now, clockSetTolerance) { return nil } return fmt.Errorf( "speaker clock unchanged after both transports (http: %v; ssh: %v). "+ "This firmware ignores POST /clockTime (it handles the POST as a read), and SSH was not usable. "+ "The durable fix is time sync: the speaker likely cannot resolve/reach an NTP server, "+ "so restore DNS/NTP reachability (a wrong clock breaks HTTPS/TLS)", httpErr, sshErr, ) } // setSpeakerClockHTTP pushes the time via POST /clockTime. Returns the // request error (a 200 here does not guarantee the clock changed; the caller // verifies separately). func (s *Server) setSpeakerClockHTTP(ip string, t time.Time) error { cfg := client.DefaultConfig() cfg.Host = ip cfg.Timeout = 5 * time.Second return client.NewClient(cfg).SetClockTime(models.NewClockTimeRequest(t)) } // setSpeakerClockSSH sets the speaker's system clock over SSH. The command is // built only from the service's own timestamp (no user input). It tries the // coreutils `-s` form first and falls back to the BusyBox positional form // (MMDDhhmmCCYY.ss), covering both firmware flavours. func setSpeakerClockSSH(ip string, t time.Time) error { utc := t.UTC() cmd := fmt.Sprintf( "date -u -s '%s' || date -u %s", utc.Format("2006-01-02 15:04:05"), utc.Format("010215042006.05"), ) out, err := ssh.NewClient(ip).Run(cmd) if err != nil { return fmt.Errorf("ssh date: %w (%s)", err, strings.TrimSpace(out)) } return nil } // speakerClockWithin reports whether the speaker's current clock is within // tol of target. Used to verify a set actually took effect. func speakerClockWithin(ip string, target time.Time, tol time.Duration) bool { cfg := client.DefaultConfig() cfg.Host = ip cfg.Timeout = 5 * time.Second ct, err := client.NewClient(cfg).GetClockTime() if err != nil || ct == nil { return false } utc := ct.GetUTC() if utc == 0 { return false } skew := target.Unix() - utc if skew < 0 { skew = -skew } return skew <= int64(tol.Seconds()) } // SetExpectedHosts records the hostnames the service considers its // own (serverURL host + httpsServerURL host + --tls-extra-host // values). The Health tab's Marge-URL check reads this list at // run time to decide whether a speaker's points at us. func (s *Server) SetExpectedHosts(hosts []string) { s.mu.Lock() defer s.mu.Unlock() out := make([]string, len(hosts)) copy(out, hosts) s.expectedHosts = out } // ExpectedHosts returns a copy of the recorded expected-hosts list. func (s *Server) ExpectedHosts() []string { s.mu.RLock() defer s.mu.RUnlock() out := make([]string, len(s.expectedHosts)) copy(out, s.expectedHosts) return out } // persistedTLSExtraHosts returns the slice of TLS extra hosts that // live in settings.json. Used by HandleGetSettings to render the // "edit list" UI separately from the full effective SAN list // (ExpectedHosts also contains serverURL host, httpsServerURL host, // hostname, and CLI/env-pinned extras). Returns an empty slice if // the settings file is missing or unreadable — the caller should // treat that the same as "operator hasn't added anything yet". func (s *Server) persistedTLSExtraHosts() []string { persisted, err := s.ds.GetSettings() if err != nil { return []string{} } out := make([]string, len(persisted.TLSExtraHosts)) copy(out, persisted.TLSExtraHosts) return out } // ownCACertPath returns the on-disk path of AfterTouch's own CA // cert (PEM). Empty string when the certmanager isn't wired in. // Used by the Health-tab CA-expiry check to render an accurate // remediation command pointing at the actual file. func (s *Server) ownCACertPath() string { if s.sm == nil || s.sm.Crypto == nil { return "" } return s.sm.Crypto.GetCACertPath() } // loadOwnCACert parses AfterTouch's own CA leaf from disk. Used // by the Health-tab cert-chain check to definitively classify // whether the HTTPS endpoint is serving a cert issued by this // service's built-in CA (as opposed to a public CA or a foreign // chain from a reverse proxy). Returns nil when the CA isn't // configured or fails to parse — the caller falls back to a // Subject==Issuer heuristic in that case. // // The parse is cached in ownCACache so repeated Health polls // don't re-read the PEM. Restart-based config changes are // picked up because Server itself is reconstructed. func (s *Server) loadOwnCACert() *x509.Certificate { s.ownCACache.once.Do(func() { if s.sm == nil || s.sm.Crypto == nil { return } path := s.sm.Crypto.GetCACertPath() if path == "" { return } data, err := os.ReadFile(path) if err != nil { return } block, _ := pem.Decode(data) if block == nil { return } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return } s.ownCACache.cert = cert }) return s.ownCACache.cert } // ClientIPMiddleware returns a chi middleware that resolves the client IP into // the request context (read via middleware.GetClientIP). Always returns a // non-nil middleware: at minimum, the socket peer is recorded. // // When Settings.TrustForwardedHeaders is true and the immediate TCP peer is in // the configured trusted-proxy list, the X-Forwarded-For header is also // consulted: chi walks the chain right-to-left, skipping entries that fall // within the trusted CIDRs, and stores the first untrusted entry as the client. // // The trusted-peer gate prevents the typical X-Forwarded-* spoofing surface: // on a flat LAN where a malicious speaker could send the headers itself, we // won't honour them; behind a documented reverse proxy on loopback we will. func (s *Server) ClientIPMiddleware() func(http.Handler) http.Handler { settings, err := s.ds.GetSettings() if err != nil { log.Printf("[ClientIP] failed to load settings: %v - falling back to peer-only", err) return clientIPMiddleware(false, nil, nil) } return buildClientIPMiddleware(settings.TrustForwardedHeaders, settings.TrustedProxyCIDRs) } // SetVersionInfo sets the version information for the server. func (s *Server) SetVersionInfo(version, commit, date, repoURL string) { s.mu.Lock() defer s.mu.Unlock() s.Version = version s.Commit = commit s.Date = date s.RepoURL = repoURL } // SetLogBuffer attaches a logbuf.Buffer to the server. When set, // HandleGetLogs returns its contents; when nil, the endpoint // reports an empty snapshot. Optional so that tests and // alternative composers (the standalone web binary, etc.) don't // have to construct a buffer they don't need. func (s *Server) SetLogBuffer(buf *logbuf.Buffer) { s.mu.Lock() defer s.mu.Unlock() s.logBuf = buf } // LogBuffer returns the attached log buffer, or nil if none. func (s *Server) LogBuffer() *logbuf.Buffer { s.mu.RLock() defer s.mu.RUnlock() return s.logBuf } // SetDiscoverySettings sets the discovery settings for the server. func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) { s.mu.Lock() defer s.mu.Unlock() s.discoveryInterval = interval s.discoveryEnabled = enabled } // SetUpdateCheckSettings sets the live update-check settings for the server. // // Kept adjacent to its getter (rather than next to GetDiscoverySettings // further down) so the pair reads as one unit; the background goroutine in // soundtouch-service re-reads them on every poll, which is what makes the // Settings-page toggle take effect without a restart. func (s *Server) SetUpdateCheckSettings(interval time.Duration, enabled bool) { s.mu.Lock() defer s.mu.Unlock() s.updateCheckInterval = interval s.updateCheckEnabled = enabled } // GetUpdateCheckSettings returns the current update-check interval and enabled state. func (s *Server) GetUpdateCheckSettings() (time.Duration, bool) { s.mu.RLock() defer s.mu.RUnlock() return s.updateCheckInterval, s.updateCheckEnabled } // SetDevicesChangedHook registers a callback fired after the known device set // changes (a discovery sweep or a manual add). The embedded web UI uses it to // re-sync its registry from the shared datastore — the single source of truth — // so it never runs its own discovery. Nil-safe. func (s *Server) SetDevicesChangedHook(hook func()) { s.mu.Lock() defer s.mu.Unlock() s.devicesChangedHook = hook } // notifyDevicesChanged fires the devices-changed hook, if one is registered. func (s *Server) notifyDevicesChanged() { s.mu.RLock() hook := s.devicesChangedHook s.mu.RUnlock() if hook != nil { hook() } } // parseUpstreamDNS splits a comma-separated string of DNS servers. func parseUpstreamDNS(upstream string) []string { var upstreamList []string if upstream != "" { for _, u := range strings.Split(upstream, ",") { u = strings.TrimSpace(u) if u != "" { upstreamList = append(upstreamList, u) } } } return upstreamList } // getSystemDNS returns the DNS servers from /etc/resolv.conf. func getSystemDNS() []string { config, _ := dns.ClientConfigFromFile("/etc/resolv.conf") if config != nil && len(config.Servers) > 0 { return config.Servers } return nil } // areUpstreamsEqual compares two slices of DNS server addresses. func areUpstreamsEqual(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // SetDNSSettings sets the DNS discovery settings for the server. func (s *Server) SetDNSSettings(enabled bool, upstream, bind string) { s.mu.Lock() defer s.mu.Unlock() oldBind := s.dnsBindAddr oldUpstream := s.dnsUpstream s.dnsEnabled = enabled s.dnsBindAddr = bind upstreamList := parseUpstreamDNS(upstream) // Try to get system DNS if none provided if enabled && len(upstreamList) == 0 { upstreamList = getSystemDNS() if len(upstreamList) > 0 { log.Printf("[DNS] Using system DNS servers from /etc/resolv.conf: %v", upstreamList) } } s.dnsUpstream = upstreamList upstreamChanged := !areUpstreamsEqual(upstreamList, oldUpstream) if s.dnsDiscovery != nil { if !enabled || bind != oldBind || upstreamChanged { log.Printf("[DNS] Settings changed, stopping DNS discovery server") _ = s.dnsDiscovery.Shutdown() s.dnsDiscovery = nil } } if enabled && len(upstreamList) == 0 { log.Printf("[DNS] Cannot start DNS discovery server: upstream DNS is empty and no system DNS found") s.dnsEnabled = false return } if enabled && s.dnsDiscovery == nil { s.startDNSDiscovery(bind, upstreamList) } } // ResolveServerURLIPForPreflight is an exported wrapper around resolveServerURLIP // so callers outside the package (e.g. the service startup pre-flight) can // reuse the same resolution path the DNS server uses. func (s *Server) ResolveServerURLIPForPreflight(serverURL string) (string, error) { return s.resolveServerURLIP(serverURL) } // resolveServerURLIP returns the IP that the DNS server would hand out as the // intercept answer for the given server URL. An empty URL, empty hostname, or a // hostname that cannot be resolved to an IP is reported as an error so callers // can refuse to start (or reject user input) instead of silently degrading. // "localhost" is treated as 127.0.0.1. func (s *Server) resolveServerURLIP(serverURL string) (string, error) { if strings.TrimSpace(serverURL) == "" { return "", fmt.Errorf("server URL is empty") } u, err := url.Parse(serverURL) if err != nil { return "", fmt.Errorf("invalid server URL %q: %w", serverURL, err) } hostname := u.Hostname() if hostname == "" { return "", fmt.Errorf("server URL %q has no hostname", serverURL) } if hostname == "localhost" { return "127.0.0.1", nil } if ip := net.ParseIP(hostname); ip != nil { return ip.String(), nil } // Prefer the setup manager's resolver (it cascades through device SSH ping // then system DNS). Fall back to plain system DNS when no manager is wired, // so this works in tests and lightweight server constructions. if s.sm != nil { if resolved := s.sm.GetResolvedIP(hostname); net.ParseIP(resolved) != nil { return resolved, nil } } else if ips, lookupErr := net.LookupIP(hostname); lookupErr == nil { for _, ip := range ips { if v4 := ip.To4(); v4 != nil { return v4.String(), nil } } if len(ips) > 0 { return ips[0].String(), nil } } return "", fmt.Errorf("hostname %q did not resolve to an IP — "+ "set the server URL to an IP, or to a hostname this host can resolve", hostname) } func (s *Server) startDNSDiscovery(bind string, upstreamList []string) { log.Printf("[DNS] Starting DNS discovery server on %s", sanitizeLog(bind)) serviceIP, err := s.resolveServerURLIP(s.serverURL) if err != nil { log.Printf("[DNS] Cannot start DNS discovery server: %s", sanitizeErr(err)) s.dnsEnabled = false return } s.dnsDiscovery = discovery.NewDNSDiscovery(upstreamList, serviceIP, s.serverURL) go func(d *discovery.DNSDiscovery, addr string) { if err := d.Start(addr); err != nil { log.Printf("Warning: DNS discovery server error: %v", err) } }(s.dnsDiscovery, bind) } // GetDNSRunning returns whether DNS discovery is active and its bind address. func (s *Server) GetDNSRunning() (bool, string) { s.mu.RLock() defer s.mu.RUnlock() if s.dnsDiscovery == nil { return false, "" } return s.dnsDiscovery.IsRunning(s.dnsBindAddr), s.dnsBindAddr } // SetDNSDiscoveries sets the initial DNS discoveries for the server. func (s *Server) SetDNSDiscoveries(discoveries map[string]*discovery.DiscoveredHost) { s.mu.Lock() defer s.mu.Unlock() if s.dnsDiscovery != nil { s.dnsDiscovery.SetDiscovered(discoveries) } } // GetDNSDiscovery returns the current DNS discoveries. func (s *Server) GetDNSDiscovery() map[string]*discovery.DiscoveredHost { s.mu.RLock() defer s.mu.RUnlock() if s.dnsDiscovery == nil { return nil } return s.dnsDiscovery.GetDiscovered() } // SetShortcuts sets the request shortcuts for the server. func (s *Server) SetShortcuts(shortcuts map[string]int) { s.mu.Lock() defer s.mu.Unlock() s.shortcuts = shortcuts } // GetShortcuts returns the current request shortcuts. func (s *Server) GetShortcuts() map[string]int { s.mu.RLock() defer s.mu.RUnlock() return s.shortcuts } // GetDiscoverySettings returns the current discovery settings. func (s *Server) GetDiscoverySettings() (time.Duration, bool) { s.mu.RLock() defer s.mu.RUnlock() return s.discoveryInterval, s.discoveryEnabled } // SetHTTPServerURL sets the external HTTPS URL of the service. // // Deprecated: prefer SetHTTPSSettings, which tracks the override vs the // derived value so the effective URL follows the Target Domain. Kept for // callers that set the effective URL directly. func (s *Server) SetHTTPServerURL(url string) { s.mu.Lock() defer s.mu.Unlock() s.httpsServerURL = url } // SetHTTPSSettings records the HTTPS URL override (empty = derive from // the Target Domain), the configured HTTPS port, and the startup // hostname-based fallback, then recomputes the effective HTTPS URL. func (s *Server) SetHTTPSSettings(override, httpsPort, defaultURL string) { s.mu.Lock() defer s.mu.Unlock() s.httpsOverride = strings.TrimSpace(override) s.httpsPort = httpsPort s.httpsDefaultURL = defaultURL s.recomputeHTTPSURLLocked() } // recomputeHTTPSURLLocked refreshes the effective HTTPS URL from the // current serverURL + override + port. Callers must hold s.mu. func (s *Server) recomputeHTTPSURLLocked() { s.httpsServerURL = DeriveHTTPSURL(s.serverURL, s.httpsOverride, s.httpsPort, s.httpsDefaultURL) } // applyHTTPSOverrideLocked sets the HTTPS override from an optional // request value (nil = preserve the current one, non-nil replaces it, // empty re-enables deriving) and recomputes the effective URL. Callers // must hold s.mu. func (s *Server) applyHTTPSOverrideLocked(override *string) { if override != nil { s.httpsOverride = strings.TrimSpace(*override) } s.recomputeHTTPSURLLocked() } // HTTPSOverride returns the explicit HTTPS URL override, or "" when the // effective URL is derived from the Target Domain. func (s *Server) HTTPSOverride() string { s.mu.RLock() defer s.mu.RUnlock() return s.httpsOverride } // SetHTTPSListenAddr records the address the HTTPS listener is bound // to (e.g. ":8443"). The cert-chain health check uses its port to // detect an advertised-URL/listener port mismatch (issue #355). func (s *Server) SetHTTPSListenAddr(addr string) { s.mu.Lock() defer s.mu.Unlock() s.httpsListenAddr = addr } // actualHTTPSPort returns the port the HTTPS listener is bound to, or // "" if unknown/unparseable. func (s *Server) actualHTTPSPort() string { s.mu.RLock() addr := s.httpsListenAddr s.mu.RUnlock() if addr == "" { return "" } _, port, err := net.SplitHostPort(addr) if err != nil { return "" } return port } // SetRecorder sets the recorder for the server. func (s *Server) SetRecorder(r *proxy.Recorder) { s.mu.Lock() defer s.mu.Unlock() s.recorder = r if r != nil { r.Redact = s.redactLogs } } // SetSpotifyConfig sets the Spotify OAuth configuration. func (s *Server) SetSpotifyConfig(clientID, clientSecret, redirectURI string) { s.mu.Lock() defer s.mu.Unlock() s.spotifyClientID = clientID s.spotifyClientSecret = clientSecret s.spotifyRedirectURI = redirectURI } // SetAmazonConfig sets the Amazon LWA OAuth configuration. func (s *Server) SetAmazonConfig(clientID, clientSecret, redirectURI string) { s.mu.Lock() defer s.mu.Unlock() s.amazonClientID = clientID s.amazonClientSecret = clientSecret s.amazonRedirectURI = redirectURI } // GetSpotifyConfig returns the current Spotify OAuth configuration. func (s *Server) GetSpotifyConfig() (clientID, clientSecret, redirectURI string) { s.mu.RLock() defer s.mu.RUnlock() return s.spotifyClientID, s.spotifyClientSecret, s.spotifyRedirectURI } // GetAmazonConfig returns the current Amazon LWA OAuth configuration. func (s *Server) GetAmazonConfig() (clientID, clientSecret, redirectURI string) { s.mu.RLock() defer s.mu.RUnlock() return s.amazonClientID, s.amazonClientSecret, s.amazonRedirectURI } // applyMusicServiceCredentials updates music service credential fields on the server. // Must be called with s.mu held. Empty string or "***" (the masked GET value) means "unchanged". func (s *Server) applyMusicServiceCredentials(spotifyID, spotifySecret, spotifyURI, amazonID, amazonSecret, amazonURI string) { if spotifyID != "" { s.spotifyClientID = spotifyID } if spotifySecret != "" && spotifySecret != "***" { s.spotifyClientSecret = spotifySecret } if spotifyURI != "" { s.spotifyRedirectURI = spotifyURI } if amazonID != "" { s.amazonClientID = amazonID } if amazonSecret != "" && amazonSecret != "***" { s.amazonClientSecret = amazonSecret } if amazonURI != "" { s.amazonRedirectURI = amazonURI } } // ReinitSpotifyService creates a new Spotify service from current config and replaces the running one. func (s *Server) ReinitSpotifyService() { clientID, clientSecret, redirectURI := s.GetSpotifyConfig() if clientID == "" { return } if redirectURI == "" { redirectURI = s.serverURL + "/mgmt/spotify/callback" } svc := spotify.NewSpotifyService(clientID, clientSecret, redirectURI, s.ds.DataDir) if err := svc.Load(); err != nil { log.Printf("[Spotify] Failed to load accounts during reinit: %v", err) } s.SetSpotifyService(svc) log.Printf("[Spotify] Service reinitialized") } // ReinitAmazonService creates a new Amazon service from current config and replaces the running one. func (s *Server) ReinitAmazonService() { clientID, clientSecret, redirectURI := s.GetAmazonConfig() if clientID == "" { return } if redirectURI == "" { redirectURI = s.serverURL + "/mgmt/amazon/callback" } svc := amazon.NewAmazonService(clientID, clientSecret, redirectURI, s.ds.DataDir) if err := svc.Load(); err != nil { log.Printf("[Amazon] Failed to load accounts during reinit: %v", err) } s.SetAmazonService(svc) log.Printf("[Amazon] Service reinitialized") } // SetMgmtConfig sets the management API authentication credentials. func (s *Server) SetMgmtConfig(username, password string) { s.mu.Lock() defer s.mu.Unlock() s.mgmtUsername = username s.mgmtPassword = password } // SetAdminAreaAuth sets the live admin-area auth mode ("" / "enabled" / // "disabled", see datastore.Settings.AdminAreaAuth). Does not validate — // callers (HandleUpdateSettings, startup settings application) are // responsible for only passing already-validated values. func (s *Server) SetAdminAreaAuth(mode string) { s.mu.Lock() defer s.mu.Unlock() s.adminAreaAuth = mode } // AdminAreaAuthMode returns the live admin-area auth mode. Exported so // packages that can't import handlers directly (e.g. health checks, which // take it via a callback to avoid a circular import) can read it. func (s *Server) AdminAreaAuthMode() string { s.mu.RLock() defer s.mu.RUnlock() return s.adminAreaAuth } // activityKindNotificationDismissed is the datastore.RecordActivity "kind" // used for announcement-banner dismissals (see #419 design, // _/i419/design-admin-area-auth-gate.md). const activityKindNotificationDismissed = "notification_dismissed" // loadDismissedAnnouncements scans the local activity log once at startup // and folds it into an id -> most-recent-dismissal-timestamp map. Called // from NewServer so the read path (IsAnnouncementDismissed) never touches // disk — only this one, scoped, boot-time scan does, regardless of how // large the log grows over time. Errors are logged, not fatal: a missing or // unreadable activity log means "nothing dismissed yet", not a startup failure. func loadDismissedAnnouncements(ds *datastore.DataStore) map[string]time.Time { dismissed := make(map[string]time.Time) if ds == nil { return dismissed } records, err := ds.GetActivityRecords(activityKindNotificationDismissed) if err != nil { log.Printf("[Announcements] Failed to load dismissal history, treating as none: %v", err) return dismissed } for _, record := range records { ts, parseErr := time.Parse(time.RFC3339Nano, record.Timestamp) if parseErr != nil { continue } if existing, ok := dismissed[record.ID]; !ok || ts.After(existing) { dismissed[record.ID] = ts } } return dismissed } // RecordDismissal marks an announcement as dismissed: appends to the local // activity log (write-through) and updates the in-memory cache immediately, // so IsAnnouncementDismissed reflects it without re-reading disk. The same // id can be dismissed again later (e.g. if re-shown) — each call is a new // log entry, not an overwrite. func (s *Server) RecordDismissal(id string) error { if s.ds != nil { if err := s.ds.RecordActivity(activityKindNotificationDismissed, id, nil); err != nil { return err } } s.mu.Lock() defer s.mu.Unlock() if s.dismissedAnnouncements == nil { s.dismissedAnnouncements = make(map[string]time.Time) } s.dismissedAnnouncements[id] = time.Now() return nil } // IsAnnouncementDismissed reports whether the given announcement id has // been dismissed, from the in-memory cache only — never touches disk. func (s *Server) IsAnnouncementDismissed(id string) bool { s.mu.RLock() defer s.mu.RUnlock() _, ok := s.dismissedAnnouncements[id] return ok } // SetUpdateChecker registers the update checker (#591). The checker itself is // always constructed and registered, regardless of whether the periodic check // is enabled, so /api/setup/version and the Announcements banner can read // LastResult() (e.g. a result persisted by an earlier run) even before the // periodic check has ever run. Only the periodic background check is gated by // the live enabled setting — see SetUpdateCheckSettings. Callers that leave // this nil are still safe: UpdateCheckResult returns the zero value. func (s *Server) SetUpdateChecker(c *updatecheck.Checker) { s.mu.Lock() defer s.mu.Unlock() s.updateChecker = c } // UpdateCheckResult returns the last known update-check result, or the // zero value (Available: false) if the check was never enabled. func (s *Server) UpdateCheckResult() updatecheck.Result { s.mu.RLock() checker := s.updateChecker s.mu.RUnlock() if checker == nil { return updatecheck.Result{} } return checker.LastResult() } // SetInternalPaths sets the internal paths for the server. func (s *Server) SetInternalPaths(paths []string) { s.mu.Lock() defer s.mu.Unlock() s.internalPaths = paths } // SetAmazonService sets the Amazon OAuth service. func (s *Server) SetAmazonService(as *amazon.Service) { s.mu.Lock() defer s.mu.Unlock() s.amazonService = as } // IsAmazonConfigured returns whether Amazon Music integration is configured. func (s *Server) IsAmazonConfigured() bool { s.mu.RLock() defer s.mu.RUnlock() return s.amazonService != nil } // SetSpotifyService sets the Spotify OAuth service. func (s *Server) SetSpotifyService(ss *spotify.Service) { s.mu.Lock() defer s.mu.Unlock() s.spotifyService = ss } // SetTTSService sets the text-to-speech service. func (s *Server) SetTTSService(t *tts.Service) { s.mu.Lock() defer s.mu.Unlock() s.ttsService = t } // ttsSvc returns the configured TTS service, or nil if none is set. func (s *Server) ttsSvc() *tts.Service { s.mu.RLock() defer s.mu.RUnlock() return s.ttsService } // SetTTSConfig sets the text-to-speech configuration. An empty endpoint keeps // the production default. Call ReinitTTSService afterwards to (re)build the // running service from this config. func (s *Server) SetTTSConfig(provider, googleAPIKey, googleEndpoint, appKey, language, voice string, volume int) { s.mu.Lock() defer s.mu.Unlock() s.ttsProvider = provider s.ttsGoogleAPIKey = googleAPIKey s.ttsGoogleEndpoint = googleEndpoint s.ttsAppKey = appKey s.ttsLanguage = language s.ttsVoice = voice s.ttsVolume = volume } // GetTTSConfig returns the current text-to-speech configuration. func (s *Server) GetTTSConfig() (provider, googleAPIKey, appKey, language, voice string, volume int) { s.mu.RLock() defer s.mu.RUnlock() return s.ttsProvider, s.ttsGoogleAPIKey, s.ttsAppKey, s.ttsLanguage, s.ttsVoice, s.ttsVolume } // applyTTSConfig updates TTS config from a settings save. Empty provider keeps // the existing one; empty or "***" secrets (API key, app key) keep the existing // value so the UI never has to round-trip them. Caller must hold s.mu. func (s *Server) applyTTSConfig(provider, googleAPIKey, appKey, language, voice string, volume int) { if provider != "" { s.ttsProvider = provider } if googleAPIKey != "" && googleAPIKey != "***" { s.ttsGoogleAPIKey = googleAPIKey } if appKey != "" && appKey != "***" { s.ttsAppKey = appKey } s.ttsLanguage = language s.ttsVoice = voice s.ttsVolume = volume } // ttsConfigured reports whether TTS is usefully configured: an app_key is // required to play on the speaker, and the google-cloud provider additionally // needs an API key. Caller must hold at least s.mu.RLock. func (s *Server) ttsConfigured() bool { if s.ttsAppKey == "" { return false } if s.ttsProvider == tts.ProviderGoogleCloud { return s.ttsGoogleAPIKey != "" } return true } // ReinitTTSService builds the TTS service from the current config and replaces // the running one. Unlike the OAuth services it always installs a service (the // translate provider needs no credentials); the provider is chosen by // s.ttsProvider, defaulting to translate. func (s *Server) ReinitTTSService() { s.mu.RLock() provider := s.ttsProvider apiKey := s.ttsGoogleAPIKey endpoint := s.ttsGoogleEndpoint cfg := tts.Config{ BaseURL: s.serverURL, AppKey: s.ttsAppKey, DefaultLanguage: s.ttsLanguage, DefaultVoice: s.ttsVoice, DefaultVolume: s.ttsVolume, } s.mu.RUnlock() var p tts.Provider switch provider { case tts.ProviderGoogleCloud: if apiKey == "" { log.Printf("[TTS] Provider 'google-cloud' selected but no API key set; synthesis will fail until one is provided") } cloud := tts.NewCloudProvider(apiKey) if endpoint != "" { cloud.SetEndpoint(endpoint) } p = cloud case tts.ProviderTranslate, "": p = tts.NewTranslateProvider() default: log.Printf("[TTS] Unknown provider %q; falling back to 'translate'", provider) p = tts.NewTranslateProvider() } s.SetTTSService(tts.NewService(p, cfg)) log.Printf("[TTS] Service reinitialized (provider: %s)", p.Name()) } // GetRecordEnabled returns whether recording is enabled. func (s *Server) GetRecordEnabled() bool { s.mu.RLock() defer s.mu.RUnlock() return s.recordEnabled } // GetSettings returns the current server settings. func (s *Server) GetSettings() (string, string) { s.mu.RLock() defer s.mu.RUnlock() return s.serverURL, s.httpsServerURL } // IsSpotifyConfigured returns whether Spotify integration is configured. func (s *Server) IsSpotifyConfigured() bool { s.mu.RLock() defer s.mu.RUnlock() return s.spotifyService != nil } // GetLoggingSettings returns the current logging settings (redact / log-body / record). func (s *Server) GetLoggingSettings() (bool, bool, bool) { s.mu.RLock() defer s.mu.RUnlock() return s.redactLogs, s.logBodies, s.recordEnabled } // DiscoverDevices starts a background device discovery process. // //nolint:contextcheck func (s *Server) DiscoverDevices(ctx context.Context) { s.discovering = true defer func() { s.discovering = false }() log.Println("Scanning for Bose devices...") // Use background context if none provided or if it's likely a request context if ctx == nil { ctx = context.Background() } // Always wrap in a timeout to prevent hanging forever discoveryCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() svc := discovery.NewService(10 * time.Second) devices, err := svc.DiscoverDevices(discoveryCtx) if err != nil { log.Printf("Discovery error: %v", err) return } for _, d := range devices { s.handleDiscoveredDevice(*d) } // Post-discovery cleanup: merge overlapping IP/Serial entries s.mergeOverlappingDevices() // Let any observer (e.g. the embedded web UI) re-sync from the datastore. s.notifyDevicesChanged() } // findExistingDeviceInfoByDeviceID looks for existing device info by deviceID func (s *Server) findExistingDeviceInfoByDeviceID(deviceID string) *models.ServiceDeviceInfo { allDevices, err := s.ds.ListAllDevices() if err != nil { return nil } for i := range allDevices { device := &allDevices[i] if device.DeviceID == deviceID { return device } } return nil } // PrimeDeviceWithSpotify triggers a Spotify priming of the speaker if a Spotify account is linked. func (s *Server) PrimeDeviceWithSpotify(deviceIP string) { s.mu.RLock() svc := s.spotifyService s.mu.RUnlock() if svc == nil { return } accounts := svc.GetAccounts() if len(accounts) == 0 { return } // We'll use the first linked account. In the future, we might want to let the user // pick or map accounts to speakers, but for now, we follow the "One linked account" model. accessToken, username, err := svc.GetFreshToken() if err != nil { log.Printf("[Spotify Watchdog] Failed to get fresh token for %s: %v", sanitizeLog(deviceIP), err) return } log.Printf("[Spotify Watchdog] Proactively priming %s with Spotify user %s", sanitizeLog(deviceIP), sanitizeLog(username)) // Register the SPOTIFY source in our marge datastore before pushing credentials. // Without this, storePreset later fails with "AddPreset - failed due to invalid SourceID" // because marge.UpdatePreset can't match SourceID="SPOTIFY" against any ConfiguredSource. s.registerSpotifySourceForDevice(deviceIP, accounts) if err := s.pushSpotifyTokenToDevice(deviceIP, username, accessToken); err != nil { // addUser may return a benign 404+empty-body no-op when the speaker // already has the activeUser set. The zeroconf-level log already // recorded the specifics; here we just upgrade the watchdog's view to // "primed" since marge holds the authoritative SPOTIFY source. if errors.Is(err, spotify.ErrAddUserNoOp) { log.Printf("[Spotify Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", sanitizeLog(deviceIP)) } else { log.Printf("[Spotify Watchdog] Failed to prime %s: %s", sanitizeLog(deviceIP), sanitizeErr(err)) } } else { log.Printf("[Spotify Watchdog] Successfully primed %s", sanitizeLog(deviceIP)) } } // registerSpotifySourceForDevice writes a SPOTIFY ConfiguredSource into the marge // datastore under the device's currently-paired account. No-op (with a log // message) if the device can't be resolved to an account — falling back to // "default" here would risk polluting an unrelated account's source list, and // any storePreset the device sends will be under its real paired account anyway. func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spotify.Account) { host := deviceIP if h, _, err := net.SplitHostPort(deviceIP); err == nil { host = h } accountID, deviceID := s.resolvePairedAccount(deviceIP, host) if accountID == "" { log.Printf("[Spotify Watchdog] No paired account for %s yet — skipping marge source registration", sanitizeLog(deviceIP)) return } registered := false for _, acc := range accounts { credential := acc.BoseSecret if credential == "" { credential = acc.AccessToken } if _, err := marge.AddSource(s.ds, accountID, acc.UserID, strconv.Itoa(constants.SpotifyProviderID), credential, "token_version_3", acc.DisplayName); err != nil { log.Printf("[Spotify Watchdog] Failed to register Spotify source for account %s: %v", sanitizeLog(accountID), err) continue } log.Printf("[Spotify Watchdog] Registered Spotify source %s for account %s (device %s)", sanitizeLog(acc.UserID), sanitizeLog(accountID), sanitizeLog(deviceID)) registered = true } // Tell the speaker its sources list changed so it re-fetches from marge. // Without this its on-device Sources.xml stays stale until something else // triggers a sync — which leaves storePreset failing with // "AddPreset - failed due to invalid SourceID" even though our marge // datastore already has the SPOTIFY entry. if registered && deviceID != "" { c := client.NewClientFromHost(deviceIP) if err := c.NotifySourcesUpdated(deviceID); err != nil { log.Printf("[Spotify Watchdog] sourcesUpdated notification for %s failed: %v", sanitizeLog(deviceIP), err) } else { log.Printf("[Spotify Watchdog] Notified %s to re-sync sources (deviceID=%s)", sanitizeLog(deviceIP), sanitizeLog(deviceID)) } } } // resolvePairedAccount returns the device's currently-paired account ID and its // canonical deviceID. It prefers the live :8090/info margeAccountUUID (matches // what the device will actually send on storePreset) and falls back to the // datastore record. Mirrors setup.populateDeviceInfo's resolution order so // priming and migration agree on which account a device belongs to. // // deviceIP is the original input (may carry a :port for tests); host is the // bare host for datastore IPAddress matching. func (s *Server) resolvePairedAccount(deviceIP, host string) (accountID, deviceID string) { if devInfo := s.findExistingDeviceInfoByIP(host); devInfo != nil { accountID = devInfo.AccountID deviceID = devInfo.DeviceID } if s.sm != nil { if info, err := s.sm.GetLiveDeviceInfo(deviceIP); err == nil { if info.MargeAccountUUID != "" { accountID = info.MargeAccountUUID } if info.DeviceID != "" { deviceID = info.DeviceID } } else { log.Printf("[Spotify Watchdog] live /info lookup for %s failed: %s (falling back to datastore account=%q)", sanitizeLog(deviceIP), sanitizeErr(err), sanitizeLog(accountID)) } } return accountID, deviceID } // findExistingDeviceInfoByIP looks up a device record by IP address across all accounts. func (s *Server) findExistingDeviceInfoByIP(ip string) *models.ServiceDeviceInfo { allDevices, err := s.ds.ListAllDevices() if err != nil { return nil } for i := range allDevices { if allDevices[i].IPAddress == ip { return &allDevices[i] } } return nil } func (s *Server) pushSpotifyTokenToDevice(deviceIP, username, accessToken string) error { host, port, err := net.SplitHostPort(deviceIP) if err != nil { // deviceIP has no port component — use the standard ZeroConf port. host = deviceIP port = "8200" } return spotify.PushSpotifyCredentials(host, port, username, accessToken) } // PrimeDeviceWithAmazon triggers an Amazon Music priming of the speaker if an Amazon account is linked. func (s *Server) PrimeDeviceWithAmazon(deviceIP string) { s.mu.RLock() svc := s.amazonService s.mu.RUnlock() if svc == nil { return } accounts := svc.GetAccounts() if len(accounts) == 0 { return } accessToken, username, err := svc.GetFreshToken() if err != nil { log.Printf("[Amazon Watchdog] Failed to get fresh token for %s: %v", sanitizeLog(deviceIP), err) return } log.Printf("[Amazon Watchdog] Proactively priming %s with Amazon user %s", sanitizeLog(deviceIP), sanitizeLog(username)) if err := s.pushAmazonTokenToDevice(deviceIP, username, accessToken); err != nil { if errors.Is(err, amazon.ErrAddUserNoOp) { log.Printf("[Amazon Watchdog] Successfully primed %s (ZeroConf addUser was an expected no-op)", sanitizeLog(deviceIP)) } else { log.Printf("[Amazon Watchdog] Failed to prime %s: %v", sanitizeLog(deviceIP), err) } } else { log.Printf("[Amazon Watchdog] Successfully primed %s", sanitizeLog(deviceIP)) } } func (s *Server) pushAmazonTokenToDevice(deviceIP, username, accessToken string) error { host, port, err := net.SplitHostPort(deviceIP) if err != nil { // deviceIP has no port component — use the standard ZeroConf port. host = deviceIP port = "8200" } return amazon.PushAmazonCredentials(host, port, username, accessToken) } func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) { log.Printf("Discovered Bose device: %s at %s (Serial: %s)", sanitizeLog(d.Name), sanitizeLog(d.Host), sanitizeLog(d.SerialNo)) // 1. Always fetch live device info from /info endpoint as the authoritative source liveInfo, err := s.sm.GetLiveDeviceInfo(d.Host) if err != nil { log.Printf("Failed to fetch live device info for %s at %s: %s", sanitizeLog(d.Name), sanitizeLog(d.Host), sanitizeErr(err)) // Fallback to discovery info if /info is not available s.handleDiscoveredDeviceFallback(d) return } // 2. Use deviceID from /info as the canonical device identifier deviceID := liveInfo.DeviceID if deviceID == "" { log.Printf("No deviceID found in /info response for %s at %s, using fallback", sanitizeLog(d.Name), sanitizeLog(d.Host)) s.handleDiscoveredDeviceFallback(d) return } log.Printf("Using deviceID '%s' from /info for device %s at %s", sanitizeLog(deviceID), sanitizeLog(d.Name), sanitizeLog(d.Host)) // 3. Get account ID from live info or fallback to existing/default storedAccount := "" if existing := s.findExistingDeviceInfoByDeviceID(deviceID); existing != nil { storedAccount = existing.AccountID } accountID := liveInfo.MargeAccountUUID if accountID == "" { accountID = storedAccount } if accountID == "" { accountID = "default" } // If the speaker reports a paired account that differs from the stored // location, migrate the device directory so ListAllDevices doesn't return duplicates. if liveInfo.MargeAccountUUID != "" && storedAccount != "" && liveInfo.MargeAccountUUID != storedAccount { if err := s.ds.MoveDevice(storedAccount, accountID, deviceID); err != nil { log.Printf("Failed to migrate device %s from %s to %s: %v", sanitizeLog(deviceID), sanitizeLog(storedAccount), sanitizeLog(accountID), err) } } // 4. Get primary MAC address from networkInfo macAddress := liveInfo.GetPrimaryMacAddress() // 5. Build complete device info from live data info := &models.ServiceDeviceInfo{ DeviceID: deviceID, // Use deviceID from /info (MAC address) AccountID: accountID, Name: liveInfo.Name, // Use name from /info IPAddress: d.Host, // IP from discovery MacAddress: macAddress, // MAC from /info networkInfo DeviceSerialNumber: liveInfo.SerialNumber, // Serial from components ProductCode: liveInfo.Type + " " + liveInfo.ModuleType, // Type + ModuleType FirmwareVersion: liveInfo.SoftwareVer, ProductSerialNumber: "", // Will be populated from components if available DiscoveryMethod: d.DiscoveryMethod, } // 6. Extract product serial number from PackagedProduct component for _, comp := range liveInfo.Components { if comp.Category == "PackagedProduct" && comp.SerialNumber != "" { info.ProductSerialNumber = comp.SerialNumber break } } // 7. Save the updated device info if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { log.Printf("Failed to save device info for %s: %s", sanitizeLog(deviceID), sanitizeErr(err)) return } // If the device was (or needed to be) relocated to a different account, ensure the // stale source entry is gone. MoveDevice's rename is a no-op if the target already // existed (e.g. partial duplicate state), leaving the source dir behind; removing it // here is safe because SaveDeviceInfo above has already written fresh data to // accountID. RemoveDevice returns nil when the path does not exist, so this is also // a harmless no-op when MoveDevice already renamed the directory successfully. if storedAccount != "" && storedAccount != accountID { if err := s.ds.RemoveDevice(storedAccount, deviceID); err != nil { log.Printf("Failed to remove stale device entry for %s in %s: %v", sanitizeLog(deviceID), sanitizeLog(storedAccount), err) } } // 8. Create default Sources.xml only when no sources file exists yet if !s.ds.HasConfiguredSources(accountID, deviceID) { if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil { log.Printf("Creating default Sources.xml for device %s", sanitizeLog(deviceID)) if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil { log.Printf("Failed to save default sources for %s: %s", sanitizeLog(deviceID), sanitizeErr(err)) } } } log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", sanitizeLog(info.Name), sanitizeLog(d.Host), sanitizeLog(deviceID)) } // handleDiscoveredDeviceFallback handles device discovery when /info endpoint is not available func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) { log.Printf("Using fallback discovery method for device: %s at %s", sanitizeLog(d.Name), sanitizeLog(d.Host)) // Use discovery data as-is with the old logic existingID := s.findExistingDeviceID(d) deviceID := d.SerialNo if deviceID == "" { deviceID = d.Host } accountID := "default" if existing := s.findExistingDeviceInfo(d); existing != nil { accountID = existing.AccountID } info := &models.ServiceDeviceInfo{ DeviceID: deviceID, AccountID: accountID, Name: d.Name, IPAddress: d.Host, DeviceSerialNumber: d.SerialNo, ProductCode: d.ModelID, FirmwareVersion: "0.0.0", // Unknown from discovery DiscoveryMethod: d.DiscoveryMethod, } // If we had an IP-based entry and now have a Serial, clean up the IP-based entry if d.SerialNo != "" && existingID != "" && existingID != d.SerialNo { log.Printf("Device %s previously known as %s, migrating to serial-based ID %s", sanitizeLog(d.Name), sanitizeLog(existingID), sanitizeLog(d.SerialNo)) _ = s.ds.RemoveDevice(accountID, existingID) } if err := s.ds.SaveDeviceInfo(accountID, deviceID, info); err != nil { log.Printf("Failed to save device info for %s: %s", sanitizeLog(deviceID), sanitizeErr(err)) return } // Create default Sources.xml only when no sources file exists yet if !s.ds.HasConfiguredSources(accountID, deviceID) { if sources, err := s.ds.GetConfiguredSources(accountID, deviceID); err == nil { log.Printf("Creating default Sources.xml for device %s (fallback)", sanitizeLog(deviceID)) if err := s.ds.SaveConfiguredSources(accountID, deviceID, sources); err != nil { log.Printf("Failed to save default sources for %s: %s", sanitizeLog(deviceID), sanitizeErr(err)) } } } log.Printf("Successfully saved device %s (%s) with fallback deviceID: %s", sanitizeLog(info.Name), sanitizeLog(d.Host), sanitizeLog(deviceID)) } func (s *Server) mergeOverlappingDevices() { allDevices, err := s.ds.ListAllDevices() if err != nil { return } // Group devices by IP byIP := make(map[string][]models.ServiceDeviceInfo) for i := range allDevices { dev := allDevices[i] if dev.IPAddress != "" { byIP[dev.IPAddress] = append(byIP[dev.IPAddress], dev) } } for ip, devices := range byIP { if len(devices) <= 1 { continue } // We have multiple entries for the same IP. // Try to find one with a Serial Number to be the master. var master *models.ServiceDeviceInfo for i := range devices { if devices[i].DeviceSerialNumber != "" { master = &devices[i] break } } if master == nil { // Fallback: look for one with DeviceID that isn't the IP for i := range devices { if devices[i].DeviceID != "" && devices[i].DeviceID != devices[i].IPAddress { master = &devices[i] break } } } if master == nil { // None have serials, just keep the first one continue } masterID := master.DeviceID if masterID == "" { masterID = master.DeviceSerialNumber } for i := range devices { dev := devices[i] devID := dev.DeviceID if devID == "" { devID = dev.IPAddress } if devID != masterID && dev.IPAddress == ip { log.Printf("Merging overlapping device entry %s into %s (IP: %s)", sanitizeLog(devID), sanitizeLog(masterID), sanitizeLog(ip)) _ = s.ds.RemoveDevice(dev.AccountID, devID) } } } } func (s *Server) findExistingDeviceID(d models.DiscoveredDevice) string { info := s.findExistingDeviceInfo(d) if info != nil { return info.DeviceID } return "" } func (s *Server) findExistingDeviceInfo(d models.DiscoveredDevice) *models.ServiceDeviceInfo { allDevices, _ := s.ds.ListAllDevices() for i := range allDevices { known := allDevices[i] // Match by Serial if d.SerialNo != "" && (known.DeviceID == d.SerialNo || known.DeviceSerialNumber == d.SerialNo) { return &known } // Match by IP if d.Host != "" && known.IPAddress == d.Host { return &known } } return nil } func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) { s.mu.RLock() defer s.mu.RUnlock() // 1. Try to find in Datastore devices, err := s.ds.ListAllDevices() if err == nil { for i := range devices { if devices[i].DeviceID == deviceID { return devices[i].IPAddress, nil } } } return "", fmt.Errorf("device not found: %s", deviceID) }