mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37e94b5f8 | ||
|
|
4e33f6948f | ||
|
|
743ff5e061 | ||
|
|
ec8bbb2f86 | ||
|
|
e75e2bea0c | ||
|
|
dd5aa2ad53 | ||
|
|
aced0f3f81 | ||
|
|
a886518cad | ||
|
|
dc81b0aa81 | ||
|
|
c648027735 | ||
|
|
fced88a8a6 | ||
|
|
0ee673c097 | ||
|
|
395b2fec8e | ||
|
|
be7e44e14b | ||
|
|
a87783d8c6 | ||
|
|
be017440b7 | ||
|
|
10de011c18 | ||
|
|
f7b74db3ea | ||
|
|
72d75133c4 | ||
|
|
e4c12471b4 |
@@ -41,3 +41,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
|
||||
# Alternative format examples:
|
||||
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
|
||||
|
||||
# Spotify Integration
|
||||
# Create an app at https://developer.spotify.com/dashboard
|
||||
# SPOTIFY_CLIENT_ID=your_client_id
|
||||
# SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
# Auth confirmation url using GET, works in browsers
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
|
||||
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
|
||||
|
||||
# Management API Authentication
|
||||
# Protects /mgmt/* endpoints (Spotify token access, account management)
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change_me!
|
||||
|
||||
# External base URL (required when behind a reverse proxy for OAuth callbacks)
|
||||
# BASE_URL=https://your-server.example.com
|
||||
|
||||
@@ -320,8 +320,8 @@ func main() {
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
// Play Text-to-Speech message
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", 70)
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -389,6 +389,10 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
|
||||
if !filters["userActivity"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
if !filters["userInactivity"] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +406,12 @@ func handleSpecialMessage(message *models.SpecialMessage, filters map[string]boo
|
||||
case models.MessageTypeUserActivity:
|
||||
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -35,23 +34,12 @@ func playTTS(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// URL encode the text for Google TTS
|
||||
encodedText := url.QueryEscape(text)
|
||||
|
||||
// Build TTS URL with language support
|
||||
ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
|
||||
|
||||
// Create PlayInfo for TTS
|
||||
playInfo := &models.PlayInfo{
|
||||
URL: ttsURL,
|
||||
AppKey: appKey,
|
||||
Service: "TTS Notification",
|
||||
Message: "Google TTS",
|
||||
Reason: text,
|
||||
}
|
||||
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo.SetVolume(volume)
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
|
||||
} else {
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
@@ -121,10 +109,11 @@ func playURL(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Create PlayInfo for URL content
|
||||
playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo.SetVolume(volume)
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
|
||||
} else {
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"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/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -155,6 +156,39 @@ func main() {
|
||||
Value: ":53",
|
||||
EnvVars: []string{"DNS_BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-id",
|
||||
Usage: "Spotify OAuth client ID",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_ID"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-client-secret",
|
||||
Usage: "Spotify OAuth client secret",
|
||||
EnvVars: []string{"SPOTIFY_CLIENT_SECRET"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spotify-redirect-uri",
|
||||
Usage: "Spotify OAuth redirect URI",
|
||||
Value: "ueberboese-login://spotify",
|
||||
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-username",
|
||||
Usage: "Management API username for HTTP Basic Auth",
|
||||
Value: "admin",
|
||||
EnvVars: []string{"MGMT_USERNAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mgmt-password",
|
||||
Usage: "Management API password for HTTP Basic Auth",
|
||||
Value: "change_me!",
|
||||
EnvVars: []string{"MGMT_PASSWORD"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "base-url",
|
||||
Usage: "External base URL for OAuth callbacks behind reverse proxy",
|
||||
EnvVars: []string{"BASE_URL"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
config := loadConfig(c)
|
||||
@@ -184,6 +218,25 @@ func main() {
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, persisted.DNSUpstream, persisted.DNSBindAddr)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
|
||||
if config.spotifyClientID != "" {
|
||||
spotifyService := spotify.NewSpotifyService(
|
||||
config.spotifyClientID,
|
||||
config.spotifyClientSecret,
|
||||
config.spotifyRedirectURI,
|
||||
config.dataDir,
|
||||
)
|
||||
server.SetSpotifyService(spotifyService)
|
||||
|
||||
clientIDPrefix := config.spotifyClientID
|
||||
if len(clientIDPrefix) > 8 {
|
||||
clientIDPrefix = clientIDPrefix[:8]
|
||||
}
|
||||
|
||||
log.Printf("Spotify service initialized (client ID: %s...)", clientIDPrefix)
|
||||
}
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
@@ -298,6 +351,11 @@ type serviceConfig struct {
|
||||
dnsBind string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
}
|
||||
|
||||
func loadConfig(c *cli.Context) serviceConfig {
|
||||
@@ -356,6 +414,12 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
spotifyClientID := c.String("spotify-client-id")
|
||||
spotifyClientSecret := c.String("spotify-client-secret")
|
||||
spotifyRedirectURI := c.String("spotify-redirect-uri")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
@@ -374,6 +438,11 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsBind: dnsBind,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +655,25 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Post("/error", server.HandleErrorStats)
|
||||
})
|
||||
|
||||
r.Route("/mgmt", func(r chi.Router) {
|
||||
// Browser OAuth callback — no auth required (Spotify redirects the
|
||||
// user's browser here directly). The authorization code is single-use,
|
||||
// short-lived, and useless without the client_secret.
|
||||
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
|
||||
|
||||
// All other management endpoints require Basic Auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/proxy/*", server.HandleProxyRequest)
|
||||
|
||||
r.Route("/setup", func(r chi.Router) {
|
||||
@@ -621,6 +709,7 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Delete("/interactions/sessions", server.HandleCleanupSessions)
|
||||
|
||||
r.Get("/dns-discoveries", server.HandleGetDNSDiscoveries)
|
||||
r.Get("/dns-discoveries/download", server.HandleDownloadDNSDiscoveries)
|
||||
r.Delete("/dns-discoveries", server.HandleClearDNSDiscoveries)
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
|
||||
@@ -370,7 +370,7 @@ soundtouch-cli speaker beep
|
||||
**Go Client Usage:**
|
||||
```go
|
||||
// Text-to-Speech
|
||||
client.PlayTTS("Hello World", "your-app-key", 70)
|
||||
client.PlayTTS("Hello World", "your-app-key", "EN", 70)
|
||||
|
||||
// URL content
|
||||
client.PlayURL("https://example.com/audio.mp3", "your-app-key", "Service", "Message", "Reason", 60)
|
||||
@@ -1044,4 +1044,4 @@ The SoundTouch Plus Wiki provides comprehensive documentation for **64 additiona
|
||||
|
||||
This documentation provides the complete foundation for implementing all endpoints from the SoundTouch Plus Wiki, enabling this Go library to become the definitive SoundTouch integration solution for everything from basic home automation to professional audio installations.
|
||||
|
||||
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
|
||||
*All examples and XML structures are verified against real SoundTouch hardware and extensively tested by the SoundTouch Plus community.*
|
||||
|
||||
@@ -68,14 +68,14 @@ func main() {
|
||||
|
||||
client := client.NewClient(config)
|
||||
|
||||
// Play TTS at current volume
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
|
||||
// Play TTS at current volume (language code "EN", "DE", etc.)
|
||||
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Play TTS at specific volume (70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
|
||||
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ You'll need to provide your own application key. The format and generation metho
|
||||
|
||||
```go
|
||||
// Doorbell notification
|
||||
client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
|
||||
client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
|
||||
|
||||
// Security alert
|
||||
client.PlayURL(
|
||||
@@ -311,4 +311,4 @@ soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ring
|
||||
4. **URL content fails**: Ensure URL is accessible and contains valid audio
|
||||
5. **Volume not restored**: May occur if device is powered off during playback
|
||||
|
||||
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
|
||||
|
||||
@@ -1769,8 +1769,8 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
|
||||
}
|
||||
|
||||
// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
|
||||
func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
|
||||
playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
|
||||
func (c *Client) PlayTTS(text, appKey, language string, volume ...int) error {
|
||||
playInfo := models.NewTTSPlayInfo(text, appKey, language, volume...)
|
||||
|
||||
if err := playInfo.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid TTS request: %w", err)
|
||||
|
||||
+10
-2
@@ -155,13 +155,17 @@ func (d *DNSDiscovery) shouldIntercept(hostname string) bool {
|
||||
"marge.bose.com",
|
||||
"bmx.bose.com",
|
||||
"streaming.bose.com",
|
||||
"streamingoauth.bose.com",
|
||||
"updates.bose.com",
|
||||
"stats.bose.com",
|
||||
"content.api.bose.io",
|
||||
"events.api.bosecm.com",
|
||||
"bose-prod.apigee.net",
|
||||
"bose-test.apigee.net",
|
||||
"worldwide.bose.com",
|
||||
"music.api.bose.com",
|
||||
"bosecm.com",
|
||||
"bose.io",
|
||||
}
|
||||
|
||||
for _, service := range interceptList {
|
||||
@@ -327,6 +331,10 @@ func (d *DNSDiscovery) Start(addr string) error {
|
||||
Net: "tcp",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
// Capture server references before releasing mutex to avoid race condition
|
||||
udpServer := d.udpServer
|
||||
tcpServer := d.tcpServer
|
||||
d.mu.Unlock()
|
||||
|
||||
errChan := make(chan error, 2)
|
||||
@@ -334,7 +342,7 @@ func (d *DNSDiscovery) Start(addr string) error {
|
||||
go func() {
|
||||
log.Printf("[DNS] UDP Discovery server starting on %s", addr)
|
||||
|
||||
if err := d.udpServer.ListenAndServe(); err != nil {
|
||||
if err := udpServer.ListenAndServe(); err != nil {
|
||||
errChan <- fmt.Errorf("UDP server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
@@ -342,7 +350,7 @@ func (d *DNSDiscovery) Start(addr string) error {
|
||||
go func() {
|
||||
log.Printf("[DNS] TCP Discovery server starting on %s", addr)
|
||||
|
||||
if err := d.tcpServer.ListenAndServe(); err != nil {
|
||||
if err := tcpServer.ListenAndServe(); err != nil {
|
||||
errChan <- fmt.Errorf("TCP server failed: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -38,6 +38,24 @@ func TestDNSDiscovery_Interception(t *testing.T) {
|
||||
t.Errorf("Expected A record, got %T", rw.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test intercepting streamingoauth.bose.com
|
||||
m3 := new(dns.Msg)
|
||||
m3.SetQuestion("streamingoauth.bose.com.", dns.TypeA)
|
||||
rw3 := &mockResponseWriter{}
|
||||
d.ServeDNS(rw3, m3)
|
||||
|
||||
if rw3.msg == nil || len(rw3.msg.Answer) == 0 {
|
||||
t.Fatal("Expected response for streamingoauth.bose.com")
|
||||
}
|
||||
|
||||
if a, ok := rw3.msg.Answer[0].(*dns.A); ok {
|
||||
if a.A.String() != serviceIP {
|
||||
t.Errorf("Expected intercepted IP %s for streamingoauth.bose.com, got %s", serviceIP, a.A.String())
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected A record for streamingoauth.bose.com, got %T", rw3.msg.Answer[0])
|
||||
}
|
||||
|
||||
// Test aftertouch.test
|
||||
m2 := new(dns.Msg)
|
||||
m2.SetQuestion("aftertouch.test.", dns.TypeA)
|
||||
|
||||
@@ -3,6 +3,8 @@ package models
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Error constants for speaker validation
|
||||
@@ -57,9 +59,9 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
|
||||
}
|
||||
|
||||
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
|
||||
func NewTTSPlayInfo(text, appKey string, volume ...int) *PlayInfo {
|
||||
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
|
||||
// URL encode the text for Google TTS
|
||||
url := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=" + text
|
||||
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
|
||||
|
||||
playInfo := &PlayInfo{
|
||||
XMLName: xml.Name{Local: "play_info"},
|
||||
|
||||
@@ -35,9 +35,9 @@ func TestNewPlayInfo(t *testing.T) {
|
||||
|
||||
func TestNewTTSPlayInfo(t *testing.T) {
|
||||
// Test without volume
|
||||
playInfo := NewTTSPlayInfo("Hello World", "test-key")
|
||||
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
|
||||
|
||||
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello World"
|
||||
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
|
||||
if playInfo.URL != expectedURL {
|
||||
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test with volume
|
||||
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", 50)
|
||||
playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", "EN", 50)
|
||||
if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 50 {
|
||||
t.Errorf("Expected Volume to be 50, got %v", playInfoWithVolume.Volume)
|
||||
}
|
||||
|
||||
+38
-2
@@ -304,8 +304,9 @@ type SpecialMessageType string
|
||||
|
||||
// Constants for special message types
|
||||
const (
|
||||
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
|
||||
MessageTypeUserActivity SpecialMessageType = "userActivity"
|
||||
MessageTypeSdkInfo SpecialMessageType = "sdkInfo"
|
||||
MessageTypeUserActivity SpecialMessageType = "userActivity"
|
||||
MessageTypeUserInactivity SpecialMessageType = "userInactivity"
|
||||
)
|
||||
|
||||
// SoundTouchSdkInfo represents the SDK info message sent on connection
|
||||
@@ -321,6 +322,12 @@ type UserActivityUpdate struct {
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
}
|
||||
|
||||
// UserInactivityUpdate represents user inactivity notifications
|
||||
type UserInactivityUpdate struct {
|
||||
XMLName xml.Name `xml:"userInactivityUpdate"`
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
}
|
||||
|
||||
// SpecialMessage represents non-updates WebSocket messages
|
||||
type SpecialMessage struct {
|
||||
Type SpecialMessageType
|
||||
@@ -604,6 +611,22 @@ func ParseSpecialMessage(data []byte) (*SpecialMessage, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check for userInactivityUpdate
|
||||
if strings.Contains(dataStr, "<userInactivityUpdate") {
|
||||
var userInactivity UserInactivityUpdate
|
||||
if err := xml.Unmarshal(data, &userInactivity); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse userInactivityUpdate: %w", err)
|
||||
}
|
||||
|
||||
return &SpecialMessage{
|
||||
Type: MessageTypeUserInactivity,
|
||||
DeviceID: userInactivity.DeviceID,
|
||||
Data: &userInactivity,
|
||||
RawData: data,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown special message type: %s", dataStr)
|
||||
}
|
||||
|
||||
@@ -629,6 +652,17 @@ func (sm *SpecialMessage) GetUserActivity() *UserActivityUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserInactivity returns the parsed UserInactivity data if the message is of that type
|
||||
func (sm *SpecialMessage) GetUserInactivity() *UserInactivityUpdate {
|
||||
if sm.Type == MessageTypeUserInactivity {
|
||||
if userInactivity, ok := sm.Data.(*UserInactivityUpdate); ok {
|
||||
return userInactivity
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String returns a string representation of the special message
|
||||
func (sm *SpecialMessage) String() string {
|
||||
switch sm.Type {
|
||||
@@ -638,6 +672,8 @@ func (sm *SpecialMessage) String() string {
|
||||
}
|
||||
case MessageTypeUserActivity:
|
||||
return fmt.Sprintf("User Activity [Device: %s]", sm.DeviceID)
|
||||
case MessageTypeUserInactivity:
|
||||
return fmt.Sprintf("User Inactivity [Device: %s]", sm.DeviceID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Unknown Special Message - Type: %s", sm.Type)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// BasicAuthMgmt returns a Basic Auth middleware using the server's management credentials.
|
||||
func (s *Server) BasicAuthMgmt() func(http.Handler) http.Handler {
|
||||
s.mu.RLock()
|
||||
username := s.mgmtUsername
|
||||
password := s.mgmtPassword
|
||||
s.mu.RUnlock()
|
||||
|
||||
return middleware.BasicAuth("Management API", map[string]string{username: password})
|
||||
}
|
||||
|
||||
// HandleMgmtListSpeakers returns discovered speakers for the given account.
|
||||
func (s *Server) HandleMgmtListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
_ = chi.URLParam(r, "accountId")
|
||||
|
||||
allDevices, err := s.ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Failed to list devices: %v", err)
|
||||
|
||||
allDevices = nil
|
||||
}
|
||||
|
||||
type speaker struct {
|
||||
IPAddress string `json:"ipAddress"`
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
speakers := make([]speaker, 0, len(allDevices))
|
||||
for i := range allDevices {
|
||||
d := &allDevices[i]
|
||||
speakers = append(speakers, speaker{
|
||||
IPAddress: d.IPAddress,
|
||||
Name: d.Name,
|
||||
DeviceID: d.DeviceID,
|
||||
Type: d.ProductCode,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"speakers": speakers,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode speakers: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtDeviceEvents returns events for a device (currently a placeholder).
|
||||
func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
|
||||
events := s.ds.GetDeviceEvents(deviceID)
|
||||
if events == nil {
|
||||
events = nil // will marshal as empty array via wrapper
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return the events in the structure the Flutter app expects.
|
||||
// Use an explicit empty slice to ensure JSON "[]" instead of "null".
|
||||
type eventEntry struct {
|
||||
Type string `json:"type"`
|
||||
Time string `json:"time"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
result := make([]eventEntry, 0, len(events))
|
||||
for _, e := range events {
|
||||
result = append(result, eventEntry{
|
||||
Type: e.Type,
|
||||
Time: e.Time,
|
||||
Data: e.Data,
|
||||
})
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"events": result,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode events: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL.
|
||||
func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL := svc.BuildAuthorizeURL()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
if err := enc.Encode(map[string]string{
|
||||
"redirectUrl": redirectURL,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode redirect URL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyCallback is the browser OAuth callback from Spotify.
|
||||
// Not protected by Basic Auth — Spotify redirects the user's browser here directly.
|
||||
// Returns an HTML page the user can close.
|
||||
func (s *Server) HandleMgmtSpotifyCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Spotify integration not configured</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Spotify Authorization Failed</h1><p>Error: ` + errMsg + `</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Missing authorization code</h1></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.ExchangeCodeAndStore(code); err != nil {
|
||||
log.Printf("[Mgmt] Spotify callback failed: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Error</h1><p>Token exchange failed</p></body></html>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<html><body><h1>Spotify Connected</h1><p>You can close this window.</p></body></html>`))
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyConfirm exchanges an authorization code for tokens.
|
||||
// Used by the ueberboese mobile app after the deep link callback delivers the code.
|
||||
// Protected by Basic Auth.
|
||||
func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, `{"error":"missing code parameter"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.ExchangeCodeAndStore(code); err != nil {
|
||||
log.Printf("[Mgmt] Spotify confirm failed: %v", err)
|
||||
http.Error(w, `{"error":"token exchange failed"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped).
|
||||
func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"accounts": accounts,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode accounts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyToken returns a fresh Spotify access token and username.
|
||||
func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Spotify token error: %v", err)
|
||||
http.Error(w, `{"error":"no token available"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"access_token": accessToken,
|
||||
"username": username,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMgmtSpotifyEntity resolves a Spotify URI to name and image URL.
|
||||
func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to read body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
if unmarshalErr := json.Unmarshal(body, &request); unmarshalErr != nil || request.URI == "" {
|
||||
http.Error(w, `{"error":"missing or invalid uri"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name, imageURL, err := svc.ResolveEntity(request.URI)
|
||||
if err != nil {
|
||||
log.Printf("[Mgmt] Spotify entity resolve error: %v", err)
|
||||
http.Error(w, `{"error":"entity resolution failed"}`, http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"name": name,
|
||||
"imageUrl": imageURL,
|
||||
}); err != nil {
|
||||
log.Printf("[Mgmt] Failed to encode entity: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -419,6 +419,32 @@ func (s *Server) HandleRevertMigration(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// HandleGetDNSDiscoveries returns recorded DNS discoveries.
|
||||
func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
result := s.getMergedDNSDiscoveries()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDownloadDNSDiscoveries returns recorded DNS discoveries as a downloadable JSON file.
|
||||
func (s *Server) HandleDownloadDNSDiscoveries(w http.ResponseWriter, _ *http.Request) {
|
||||
result := s.getMergedDNSDiscoveries()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"dns-discoveries.json\"")
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
log.Printf("Error encoding DNS discoveries for download: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getMergedDNSDiscoveries() []datastore.DNSDiscoveryEntry {
|
||||
// 1. Get current in-memory discoveries
|
||||
inMemory := s.GetDNSDiscovery()
|
||||
|
||||
@@ -469,12 +495,7 @@ func (s *Server) HandleGetDNSDiscoveries(w http.ResponseWriter, _ *http.Request)
|
||||
log.Printf("Warning: Failed to persist merged DNS discoveries: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleClearDNSDiscoveries clears recorded DNS discoveries.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
)
|
||||
|
||||
// Server handles HTTP requests for the SoundTouch service.
|
||||
@@ -40,6 +41,12 @@ type Server struct {
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyService *spotify.Service
|
||||
}
|
||||
|
||||
// NewServer creates a new SoundTouch service server.
|
||||
@@ -215,6 +222,33 @@ func (s *Server) SetRecorder(r *proxy.Recorder) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// SetSpotifyService sets the Spotify OAuth service.
|
||||
func (s *Server) SetSpotifyService(ss *spotify.Service) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.spotifyService = ss
|
||||
}
|
||||
|
||||
// GetRecordEnabled returns whether recording is enabled.
|
||||
func (s *Server) GetRecordEnabled() bool {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -406,7 +406,10 @@
|
||||
<div id="dns-discoveries" class="summary-box" style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">DNS Discoveries</h3>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="downloadDNSDiscoveries()" class="btn-info">Download JSON</button>
|
||||
<button onclick="clearDNSDiscoveries()" class="btn-danger">Clear DNS Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #666;">Hosts discovered via the AfterTouch DNS server. "Self" means the domain was intercepted and redirected to this service.</p>
|
||||
<div id="dns-discoveries-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
|
||||
@@ -585,6 +585,10 @@ async function clearDNSDiscoveries() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadDNSDiscoveries() {
|
||||
window.location.href = '/setup/dns-discoveries/download';
|
||||
}
|
||||
|
||||
async function showDeviceEvents() {
|
||||
const overlay = document.getElementById('device-events-overlay');
|
||||
overlay.style.display = 'block';
|
||||
|
||||
@@ -106,7 +106,7 @@ func formatHeaders(h http.Header, redact bool) string {
|
||||
val = "[REDACTED]"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, val))
|
||||
fmt.Fprintf(&sb, " %s: %s\n", k, val)
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(sb.String(), "\n")
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Package spotify provides Spotify OAuth integration and token management
|
||||
// for the SoundTouch service, ported from soundcork's Python implementation.
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// SpotifyAuthorizeURL is the Spotify OAuth authorization endpoint.
|
||||
SpotifyAuthorizeURL = "https://accounts.spotify.com/authorize"
|
||||
// SpotifyTokenURL is the Spotify OAuth token endpoint.
|
||||
SpotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
// SpotifyAPIBase is the base URL for the Spotify Web API.
|
||||
SpotifyAPIBase = "https://api.spotify.com/v1"
|
||||
// SpotifyScopes are the OAuth scopes required for speaker playback and user info.
|
||||
SpotifyScopes = "streaming user-read-private user-read-email user-read-playback-state user-modify-playback-state"
|
||||
)
|
||||
|
||||
// Account represents a stored Spotify account with tokens.
|
||||
type Account struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Service manages Spotify OAuth flow and token lifecycle.
|
||||
type Service struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURI string
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
accounts map[string]*Account
|
||||
|
||||
// Overridable URLs for testing
|
||||
tokenURL string
|
||||
apiBase string
|
||||
}
|
||||
|
||||
// NewSpotifyService creates a new Service and loads any persisted accounts.
|
||||
func NewSpotifyService(clientID, clientSecret, redirectURI, dataDir string) *Service {
|
||||
s := &Service{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
redirectURI: redirectURI,
|
||||
dataDir: dataDir,
|
||||
accounts: make(map[string]*Account),
|
||||
tokenURL: SpotifyTokenURL,
|
||||
apiBase: SpotifyAPIBase,
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
log.Printf("[Spotify] Failed to load accounts: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildAuthorizeURL constructs the Spotify OAuth authorization URL.
|
||||
func (s *Service) BuildAuthorizeURL() string {
|
||||
params := url.Values{
|
||||
"client_id": {s.clientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {s.redirectURI},
|
||||
"scope": {SpotifyScopes},
|
||||
}
|
||||
|
||||
return SpotifyAuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCodeAndStore exchanges an authorization code for tokens,
|
||||
// fetches the user profile, and stores the account.
|
||||
func (s *Service) ExchangeCodeAndStore(code string) error {
|
||||
// Exchange code for tokens
|
||||
tokenResp, err := s.exchangeCode(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token exchange: %w", err)
|
||||
}
|
||||
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
if expiresIn == 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
|
||||
// Fetch user profile
|
||||
profile, err := s.getUserProfile(accessToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch profile: %w", err)
|
||||
}
|
||||
|
||||
userID, _ := profile["id"].(string)
|
||||
displayName, _ := profile["display_name"].(string)
|
||||
email, _ := profile["email"].(string)
|
||||
|
||||
account := &Account{
|
||||
UserID: userID,
|
||||
DisplayName: displayName,
|
||||
Email: email,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(expiresIn),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.accounts[userID] = account
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.save(); err != nil {
|
||||
return fmt.Errorf("save accounts: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Spotify] Account linked: %s (%s)", displayName, userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) exchangeCode(code string) (map[string]interface{}, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {s.redirectURI},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(s.clientID, s.clientSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) getUserProfile(accessToken string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, s.apiBase+"/me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profile request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse profile: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes the access token for the given account.
|
||||
func (s *Service) RefreshAccessToken(account *Account) error {
|
||||
data := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {account.RefreshToken},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(s.clientID, s.clientSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
account.AccessToken, _ = result["access_token"].(string)
|
||||
|
||||
expiresIn, _ := result["expires_in"].(float64)
|
||||
if expiresIn == 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
|
||||
account.ExpiresAt = time.Now().Unix() + int64(expiresIn)
|
||||
if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" {
|
||||
account.RefreshToken = newRefresh
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.save(); err != nil {
|
||||
return fmt.Errorf("save accounts: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFreshToken returns a valid access token and username, refreshing if needed.
|
||||
func (s *Service) GetFreshToken() (accessToken, username string, err error) {
|
||||
s.mu.RLock()
|
||||
|
||||
if len(s.accounts) == 0 {
|
||||
s.mu.RUnlock()
|
||||
return "", "", fmt.Errorf("no Spotify accounts linked")
|
||||
}
|
||||
|
||||
// Get the first account
|
||||
var account *Account
|
||||
for _, a := range s.accounts {
|
||||
account = a
|
||||
break
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Check if token needs refresh (expired or within 60s of expiry)
|
||||
if account.ExpiresAt < time.Now().Unix()+60 {
|
||||
if err := s.RefreshAccessToken(account); err != nil {
|
||||
return "", "", fmt.Errorf("refresh token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return account.AccessToken, account.UserID, nil
|
||||
}
|
||||
|
||||
// GetAccounts returns a copy of all accounts with tokens stripped for API responses.
|
||||
func (s *Service) GetAccounts() []Account {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
result := make([]Account, 0, len(s.accounts))
|
||||
for _, a := range s.accounts {
|
||||
result = append(result, Account{
|
||||
UserID: a.UserID,
|
||||
DisplayName: a.DisplayName,
|
||||
Email: a.Email,
|
||||
ExpiresAt: a.ExpiresAt,
|
||||
// AccessToken and RefreshToken deliberately omitted
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolveEntity resolves a Spotify URI to a name and image URL.
|
||||
func (s *Service) ResolveEntity(uri string) (name, imageURL string, err error) {
|
||||
entityType, entityID, err := parseSpotifyURI(uri)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
accessToken, _, err := s.GetFreshToken()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("get token: %w", err)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/%s/%s", s.apiBase, entityType, entityID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("API request: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", "", fmt.Errorf("spotify entity not found")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("spotify API error (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return "", "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
|
||||
name, _ = data["name"].(string)
|
||||
if name == "" {
|
||||
name = "Unknown"
|
||||
}
|
||||
|
||||
// Extract image URL — location varies by entity type
|
||||
imageURL = extractImageURL(data, entityType)
|
||||
|
||||
return name, imageURL, nil
|
||||
}
|
||||
|
||||
// extractImageURL extracts the first image URL from a Spotify API response.
|
||||
// For tracks, images are stored on the album object.
|
||||
func extractImageURL(data map[string]interface{}, entityType string) string {
|
||||
images, _ := data["images"].([]interface{})
|
||||
if len(images) == 0 && entityType == "tracks" {
|
||||
// Tracks store images on the album
|
||||
album, _ := data["album"].(map[string]interface{})
|
||||
if album != nil {
|
||||
images, _ = album["images"].([]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
if len(images) > 0 {
|
||||
if img, ok := images[0].(map[string]interface{}); ok {
|
||||
url, _ := img["url"].(string)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseSpotifyURI parses a Spotify URI like "spotify:track:abc" into
|
||||
// the pluralized API type ("tracks") and ID ("abc").
|
||||
func parseSpotifyURI(uri string) (entityType, entityID string, err error) {
|
||||
parts := strings.Split(uri, ":")
|
||||
if len(parts) != 3 || parts[0] != "spotify" {
|
||||
return "", "", fmt.Errorf("invalid Spotify URI format: %s", uri)
|
||||
}
|
||||
|
||||
typ := parts[1]
|
||||
id := parts[2]
|
||||
|
||||
validTypes := map[string]string{
|
||||
"track": "tracks",
|
||||
"album": "albums",
|
||||
"playlist": "playlists",
|
||||
"artist": "artists",
|
||||
}
|
||||
|
||||
plural, ok := validTypes[typ]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported Spotify entity type: %s", typ)
|
||||
}
|
||||
|
||||
return plural, id, nil
|
||||
}
|
||||
|
||||
// save persists accounts to disk as JSON.
|
||||
func (s *Service) save() error {
|
||||
s.mu.RLock()
|
||||
|
||||
data := make(map[string]*Account, len(s.accounts))
|
||||
for k, v := range s.accounts {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
s.mu.RUnlock()
|
||||
|
||||
dir := filepath.Join(s.dataDir, "spotify")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal accounts: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "accounts.json")
|
||||
if err := os.WriteFile(path, jsonData, 0600); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// load reads persisted accounts from disk.
|
||||
func (s *Service) load() error {
|
||||
path := filepath.Join(s.dataDir, "spotify", "accounts.json")
|
||||
|
||||
jsonData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No accounts file yet, not an error
|
||||
}
|
||||
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
var accounts map[string]*Account
|
||||
if err := json.Unmarshal(jsonData, &accounts); err != nil {
|
||||
return fmt.Errorf("unmarshal accounts: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.accounts = accounts
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[Spotify] Loaded %d account(s)", len(accounts))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildAuthorizeURL(t *testing.T) {
|
||||
svc := NewSpotifyService("test-client-id", "test-secret", "http://localhost/callback", t.TempDir())
|
||||
|
||||
url := svc.BuildAuthorizeURL()
|
||||
|
||||
if !strings.Contains(url, "client_id=test-client-id") {
|
||||
t.Errorf("URL should contain client_id, got: %s", url)
|
||||
}
|
||||
if !strings.Contains(url, "redirect_uri=") {
|
||||
t.Errorf("URL should contain redirect_uri, got: %s", url)
|
||||
}
|
||||
if !strings.Contains(url, "scope=") {
|
||||
t.Errorf("URL should contain scope, got: %s", url)
|
||||
}
|
||||
if !strings.Contains(url, "response_type=code") {
|
||||
t.Errorf("URL should contain response_type=code, got: %s", url)
|
||||
}
|
||||
if !strings.HasPrefix(url, SpotifyAuthorizeURL) {
|
||||
t.Errorf("URL should start with %s, got: %s", SpotifyAuthorizeURL, url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountsStripsTokens(t *testing.T) {
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
|
||||
|
||||
// Manually add an account with tokens
|
||||
svc.mu.Lock()
|
||||
svc.accounts["user1"] = &Account{
|
||||
UserID: "user1",
|
||||
DisplayName: "Test User",
|
||||
Email: "test@example.com",
|
||||
AccessToken: "secret-access-token",
|
||||
RefreshToken: "secret-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
|
||||
if len(accounts) != 1 {
|
||||
t.Fatalf("expected 1 account, got %d", len(accounts))
|
||||
}
|
||||
|
||||
if accounts[0].AccessToken != "" {
|
||||
t.Errorf("AccessToken should be stripped, got: %s", accounts[0].AccessToken)
|
||||
}
|
||||
if accounts[0].RefreshToken != "" {
|
||||
t.Errorf("RefreshToken should be stripped, got: %s", accounts[0].RefreshToken)
|
||||
}
|
||||
if accounts[0].UserID != "user1" {
|
||||
t.Errorf("UserID should be preserved, got: %s", accounts[0].UserID)
|
||||
}
|
||||
if accounts[0].DisplayName != "Test User" {
|
||||
t.Errorf("DisplayName should be preserved, got: %s", accounts[0].DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshTokenRefreshesExpired(t *testing.T) {
|
||||
// Set up a mock Spotify token endpoint
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Form.Get("grant_type") != "refresh_token" {
|
||||
t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type"))
|
||||
}
|
||||
if r.Form.Get("refresh_token") != "my-refresh-token" {
|
||||
t.Errorf("expected refresh_token=my-refresh-token, got %s", r.Form.Get("refresh_token"))
|
||||
}
|
||||
|
||||
// Verify Basic Auth
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "cid" || pass != "csecret" {
|
||||
t.Errorf("expected Basic Auth cid:csecret, got %s:%s (ok=%v)", user, pass, ok)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "new-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "new-refresh-token",
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
|
||||
|
||||
// Override the token URL for testing
|
||||
svc.tokenURL = tokenServer.URL
|
||||
|
||||
// Add an account with an expired token
|
||||
svc.mu.Lock()
|
||||
svc.accounts["user1"] = &Account{
|
||||
UserID: "user1",
|
||||
DisplayName: "Test User",
|
||||
AccessToken: "old-expired-token",
|
||||
RefreshToken: "my-refresh-token",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(), // expired
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
accessToken, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if accessToken != "new-access-token" {
|
||||
t.Errorf("expected new-access-token, got %s", accessToken)
|
||||
}
|
||||
if username != "user1" {
|
||||
t.Errorf("expected user1, got %s", username)
|
||||
}
|
||||
|
||||
// Verify the account was updated
|
||||
svc.mu.RLock()
|
||||
account := svc.accounts["user1"]
|
||||
svc.mu.RUnlock()
|
||||
|
||||
if account.RefreshToken != "new-refresh-token" {
|
||||
t.Errorf("refresh token should be updated, got %s", account.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEntityParsesURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
uri string
|
||||
expectedType string
|
||||
expectedID string
|
||||
shouldErr bool
|
||||
}{
|
||||
{"spotify:track:abc123", "tracks", "abc123", false},
|
||||
{"spotify:album:xyz789", "albums", "xyz789", false},
|
||||
{"spotify:playlist:pl1", "playlists", "pl1", false},
|
||||
{"spotify:artist:ar1", "artists", "ar1", false},
|
||||
{"invalid-uri", "", "", true},
|
||||
{"spotify:invalid_type:id", "", "", true},
|
||||
{"spotify:track", "", "", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.uri, func(t *testing.T) {
|
||||
entityType, entityID, err := parseSpotifyURI(tc.uri)
|
||||
if tc.shouldErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for URI %s", tc.uri)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for URI %s: %v", tc.uri, err)
|
||||
}
|
||||
if entityType != tc.expectedType {
|
||||
t.Errorf("expected type %s, got %s", tc.expectedType, entityType)
|
||||
}
|
||||
if entityID != tc.expectedID {
|
||||
t.Errorf("expected id %s, got %s", tc.expectedID, entityID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEntityFetchesFromAPI(t *testing.T) {
|
||||
// Mock Spotify API
|
||||
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check Authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer fresh-token" {
|
||||
t.Errorf("expected Bearer fresh-token, got %s", auth)
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/tracks/abc123":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"name": "Test Track",
|
||||
"album": map[string]interface{}{
|
||||
"images": []map[string]interface{}{
|
||||
{"url": "http://img.example.com/track.jpg"},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/albums/xyz789":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"name": "Test Album",
|
||||
"images": []map[string]interface{}{
|
||||
{"url": "http://img.example.com/album.jpg"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer apiServer.Close()
|
||||
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
|
||||
svc.apiBase = apiServer.URL
|
||||
|
||||
// Add a non-expired account
|
||||
svc.mu.Lock()
|
||||
svc.accounts["user1"] = &Account{
|
||||
UserID: "user1",
|
||||
AccessToken: "fresh-token",
|
||||
RefreshToken: "refresh",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
// Test track (images come from album)
|
||||
name, imageURL, err := svc.ResolveEntity("spotify:track:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if name != "Test Track" {
|
||||
t.Errorf("expected Test Track, got %s", name)
|
||||
}
|
||||
if imageURL != "http://img.example.com/track.jpg" {
|
||||
t.Errorf("expected track image URL, got %s", imageURL)
|
||||
}
|
||||
|
||||
// Test album (images at top level)
|
||||
name, imageURL, err = svc.ResolveEntity("spotify:album:xyz789")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if name != "Test Album" {
|
||||
t.Errorf("expected Test Album, got %s", name)
|
||||
}
|
||||
if imageURL != "http://img.example.com/album.jpg" {
|
||||
t.Errorf("expected album image URL, got %s", imageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create and populate
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir)
|
||||
svc.mu.Lock()
|
||||
svc.accounts["user1"] = &Account{
|
||||
UserID: "user1",
|
||||
DisplayName: "Test User",
|
||||
Email: "test@example.com",
|
||||
AccessToken: "at",
|
||||
RefreshToken: "rt",
|
||||
ExpiresAt: 1234567890,
|
||||
}
|
||||
svc.accounts["user2"] = &Account{
|
||||
UserID: "user2",
|
||||
DisplayName: "User Two",
|
||||
Email: "two@example.com",
|
||||
AccessToken: "at2",
|
||||
RefreshToken: "rt2",
|
||||
ExpiresAt: 9876543210,
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
// Save
|
||||
if err := svc.save(); err != nil {
|
||||
t.Fatalf("save failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
accountsFile := filepath.Join(dir, "spotify", "accounts.json")
|
||||
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
|
||||
t.Fatal("accounts.json was not created")
|
||||
}
|
||||
|
||||
// Load into new service
|
||||
svc2 := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir)
|
||||
|
||||
svc2.mu.RLock()
|
||||
defer svc2.mu.RUnlock()
|
||||
|
||||
if len(svc2.accounts) != 2 {
|
||||
t.Fatalf("expected 2 accounts after load, got %d", len(svc2.accounts))
|
||||
}
|
||||
|
||||
u1, ok := svc2.accounts["user1"]
|
||||
if !ok {
|
||||
t.Fatal("user1 not found after load")
|
||||
}
|
||||
if u1.DisplayName != "Test User" {
|
||||
t.Errorf("expected Test User, got %s", u1.DisplayName)
|
||||
}
|
||||
if u1.AccessToken != "at" {
|
||||
t.Errorf("expected at, got %s", u1.AccessToken)
|
||||
}
|
||||
if u1.ExpiresAt != 1234567890 {
|
||||
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeAndStore(t *testing.T) {
|
||||
// Mock token endpoint
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
switch r.Form.Get("grant_type") {
|
||||
case "authorization_code":
|
||||
if r.Form.Get("code") != "test-auth-code" {
|
||||
t.Errorf("expected code=test-auth-code, got %s", r.Form.Get("code"))
|
||||
}
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "cid" || pass != "csecret" {
|
||||
t.Errorf("bad Basic Auth: %s:%s ok=%v", user, pass, ok)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"access_token": "new-at",
|
||||
"refresh_token": "new-rt",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected grant_type: %s", r.Form.Get("grant_type"))
|
||||
http.Error(w, "bad request", 400)
|
||||
}
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
// Mock profile endpoint
|
||||
profileServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth != "Bearer new-at" {
|
||||
t.Errorf("expected Bearer new-at, got %s", auth)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "spotify-user-123",
|
||||
"display_name": "Spotify User",
|
||||
"email": "user@spotify.com",
|
||||
})
|
||||
}))
|
||||
defer profileServer.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", dir)
|
||||
svc.tokenURL = tokenServer.URL
|
||||
svc.apiBase = profileServer.URL
|
||||
|
||||
err := svc.ExchangeCodeAndStore("test-auth-code")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCodeAndStore failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify account stored
|
||||
svc.mu.RLock()
|
||||
account, ok := svc.accounts["spotify-user-123"]
|
||||
svc.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
t.Fatal("account not found after exchange")
|
||||
}
|
||||
if account.DisplayName != "Spotify User" {
|
||||
t.Errorf("expected Spotify User, got %s", account.DisplayName)
|
||||
}
|
||||
if account.Email != "user@spotify.com" {
|
||||
t.Errorf("expected user@spotify.com, got %s", account.Email)
|
||||
}
|
||||
if account.AccessToken != "new-at" {
|
||||
t.Errorf("expected new-at, got %s", account.AccessToken)
|
||||
}
|
||||
if account.RefreshToken != "new-rt" {
|
||||
t.Errorf("expected new-rt, got %s", account.RefreshToken)
|
||||
}
|
||||
|
||||
// Verify saved to disk
|
||||
accountsFile := filepath.Join(dir, "spotify", "accounts.json")
|
||||
data, err := os.ReadFile(accountsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read accounts file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "spotify-user-123") {
|
||||
t.Error("accounts file should contain the user ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshTokenNoAccounts(t *testing.T) {
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
|
||||
|
||||
_, _, err := svc.GetFreshToken()
|
||||
if err == nil {
|
||||
t.Error("expected error when no accounts exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFreshTokenNotExpired(t *testing.T) {
|
||||
svc := NewSpotifyService("cid", "csecret", "http://localhost/cb", t.TempDir())
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.accounts["user1"] = &Account{
|
||||
UserID: "user1",
|
||||
AccessToken: "valid-token",
|
||||
RefreshToken: "rt",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
token, username, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "valid-token" {
|
||||
t.Errorf("expected valid-token, got %s", token)
|
||||
}
|
||||
if username != "user1" {
|
||||
t.Errorf("expected user1, got %s", username)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,15 @@ REDACT_PROXY_LOGS="${REDACT_PROXY_LOGS:-true}"
|
||||
RECORD_INTERACTIONS="${RECORD_INTERACTIONS:-true}"
|
||||
DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}"
|
||||
|
||||
# Spotify OAuth config (optional)
|
||||
SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}"
|
||||
SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}"
|
||||
SPOTIFY_REDIRECT_URI="${SPOTIFY_REDIRECT_URI:-ueberboese-login://spotify}"
|
||||
|
||||
# Management API credentials
|
||||
MGMT_USERNAME="${MGMT_USERNAME:-admin}"
|
||||
MGMT_PASSWORD="${MGMT_PASSWORD:-change_me!}"
|
||||
|
||||
# Override if you want to force a specific asset suffix:
|
||||
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
|
||||
ARCH_ASSET="${ARCH_ASSET:-}"
|
||||
@@ -204,6 +213,7 @@ self_update() {
|
||||
# Export current env vars to the new script
|
||||
export IS_SELF_UPDATE="true"
|
||||
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
|
||||
export SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REDIRECT_URI MGMT_USERNAME MGMT_PASSWORD
|
||||
|
||||
exec "${tmp_script}" "$@"
|
||||
}
|
||||
@@ -222,6 +232,13 @@ DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}
|
||||
|
||||
SERVER_URL=${SERVER_URL}
|
||||
HTTPS_SERVER_URL=${HTTPS_SERVER_URL}
|
||||
|
||||
SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
||||
SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
|
||||
SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
|
||||
|
||||
MGMT_USERNAME=${MGMT_USERNAME}
|
||||
MGMT_PASSWORD=${MGMT_PASSWORD}
|
||||
EOF
|
||||
chmod 0640 "${ENV_FILE}"
|
||||
# group-readable so you can add yourself to the group if desired
|
||||
|
||||
Reference in New Issue
Block a user