mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.
The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.
Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
service via /api/device-speak/{id}.
The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1331 lines
39 KiB
Go
1331 lines
39 KiB
Go
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/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
|
|
discovering bool
|
|
redactLogs bool
|
|
logBodies bool
|
|
recordEnabled bool
|
|
discoveryInterval time.Duration
|
|
discoveryEnabled bool
|
|
dnsEnabled bool
|
|
dnsUpstream []string
|
|
dnsBindAddr string
|
|
internalPaths []string
|
|
shortcuts map[string]int
|
|
recorder *proxy.Recorder
|
|
dnsDiscovery *discovery.DNSDiscovery
|
|
Version string
|
|
Commit string
|
|
Date string
|
|
RepoURL string
|
|
mgmtUsername string
|
|
mgmtPassword string
|
|
spotifyClientID string
|
|
spotifyClientSecret string
|
|
spotifyRedirectURI string
|
|
spotifyService *spotify.Service
|
|
amazonClientID string
|
|
amazonClientSecret string
|
|
amazonRedirectURI string
|
|
amazonService *amazon.Service
|
|
ttsService *tts.Service
|
|
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)
|
|
},
|
|
}
|
|
|
|
// 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: serverURL,
|
|
redactLogs: redactLogs,
|
|
logBodies: logBodies,
|
|
recordEnabled: recordEnabled,
|
|
discoveryInterval: 5 * time.Minute,
|
|
discoveryEnabled: true,
|
|
peerObserver: newPeerObserver(),
|
|
healthRegistry: health.NewRegistry(),
|
|
}
|
|
|
|
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
|
|
health.RegisterSpeakerInfoReachable(s.healthRegistry, ds)
|
|
health.RegisterSourcesXMLDiff(s.healthRegistry, ds)
|
|
health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts)
|
|
health.RegisterCertChainCheck(
|
|
s.healthRegistry,
|
|
func() string {
|
|
_, httpsURL := s.GetSettings()
|
|
return httpsURL
|
|
},
|
|
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.RegisterServerURLReachableCheck(s.healthRegistry, func() string {
|
|
serverURL, _ := s.GetSettings()
|
|
return serverURL
|
|
})
|
|
health.RegisterOAuthTargetReachableCheck(
|
|
s.healthRegistry,
|
|
func() string {
|
|
serverURL, _ := s.GetSettings()
|
|
return serverURL
|
|
},
|
|
s.GetDNSRunning,
|
|
)
|
|
|
|
// 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
|
|
},
|
|
)
|
|
|
|
return s
|
|
}
|
|
|
|
// 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 <margeURL> 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
|
|
}
|
|
|
|
// TrustedRealIPMiddleware returns a chi middleware that rewrites
|
|
// r.RemoteAddr from X-Real-IP / X-Forwarded-For / True-Client-IP, but only
|
|
// when the immediate TCP peer is in the configured trusted-proxy list.
|
|
// Returns nil when Settings.TrustForwardedHeaders is false (the safe
|
|
// default), so the caller can skip wiring the middleware entirely.
|
|
//
|
|
// 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) TrustedRealIPMiddleware() func(http.Handler) http.Handler {
|
|
settings, err := s.ds.GetSettings()
|
|
if err != nil {
|
|
log.Printf("[RealIP] failed to load settings: %v — skipping forwarded-header trust", err)
|
|
return nil
|
|
}
|
|
|
|
if !settings.TrustForwardedHeaders {
|
|
return nil
|
|
}
|
|
|
|
cidrs, err := ParseTrustedProxyCIDRs(settings.TrustedProxyCIDRs)
|
|
if err != nil {
|
|
log.Printf("[RealIP] invalid trusted_proxy_cidrs: %v — skipping forwarded-header trust", err)
|
|
return nil
|
|
}
|
|
|
|
return TrustedRealIP(cidrs)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
func (s *Server) SetHTTPServerURL(url string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.httpsServerURL = url
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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)
|
|
}
|