mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
Implement local Bose Spotify OAuth token handling and fix linting issues in tests (#119)
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
co-authored by
Junie
parent
ba11394d0f
commit
6cf511e7e5
@@ -724,6 +724,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
})
|
||||
|
||||
r.Route("/oauth", func(r chi.Router) {
|
||||
r.Post("/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken)
|
||||
r.HandleFunc("/*", server.HandleBoseProxy)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// HandleBoseSpotifyToken handles the Bose-specific Spotify token refresh request from the speaker.
|
||||
// POST /oauth/device/{deviceID}/music/musicprovider/15/token/cs3
|
||||
func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceID")
|
||||
log.Printf("[Spotify Proxy] Intercepted token request for device %s", deviceID)
|
||||
|
||||
s.mu.RLock()
|
||||
svc := s.spotifyService
|
||||
s.mu.RUnlock()
|
||||
|
||||
if svc == nil {
|
||||
log.Printf("[Spotify Proxy] Spotify service not configured, falling back to upstream")
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
accounts := svc.GetAccounts()
|
||||
if len(accounts) == 0 {
|
||||
log.Printf("[Spotify Proxy] No Spotify accounts linked, falling back to upstream")
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// We use the first linked account.
|
||||
accessToken, _, err := svc.GetFreshToken()
|
||||
if err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err)
|
||||
s.HandleBoseProxy(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Format response as expected by Bose firmware.
|
||||
// Based on observed interactions, it's a JSON object with access_token.
|
||||
// The "scope" and other fields might be needed by some firmware versions.
|
||||
response := map[string]interface{}{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
// These scopes are typical for what Bose requests.
|
||||
"scope": "playlist-read-private playlist-read-collaborative streaming user-library-read user-library-modify playlist-modify-private playlist-modify-public user-read-email user-read-private user-top-read",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Proxy-Origin", "self")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("[Spotify Proxy] Failed to encode response: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBoseSpotifyLegacyToken handles the Bose-specific Spotify token refresh request (legacy or variant).
|
||||
// POST /oauth/device/{deviceID}/music/musicprovider/15/token
|
||||
func (s *Server) HandleBoseSpotifyLegacyToken(w http.ResponseWriter, r *http.Request) {
|
||||
// Some firmware might use a slightly different path.
|
||||
s.HandleBoseSpotifyToken(w, r)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestHandleBoseSpotifyToken_LocalResponse(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Wait, I can't easily inject an account into spotify.Service from here because fields are private.
|
||||
// Let's check if there's any other way.
|
||||
// I could mock the spotify.Service if it was an interface, but it's a struct.
|
||||
|
||||
// ss.load() expects accounts in tmpDir/spotify/accounts.json
|
||||
spotifyDir := filepath.Join(tmpDir, "spotify")
|
||||
_ = os.MkdirAll(spotifyDir, 0755)
|
||||
|
||||
account := map[string]interface{}{
|
||||
"user1": map[string]interface{}{
|
||||
"user_id": "user1",
|
||||
"display_name": "Test User",
|
||||
"access_token": "valid-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(1 * time.Hour).Unix(),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(account)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal account: %v", err)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), data, 0644)
|
||||
|
||||
// Initialize ss so it loads the data
|
||||
ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir)
|
||||
|
||||
server.SetSpotifyService(ss)
|
||||
|
||||
// chi.URLParam works when using chi router
|
||||
r := chi.NewRouter()
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
|
||||
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Header().Get("X-Proxy-Origin") != "self" {
|
||||
t.Errorf("Expected X-Proxy-Origin: self, got %s", w.Header().Get("X-Proxy-Origin"))
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if resp["access_token"] != "valid-token" {
|
||||
t.Errorf("Expected access_token 'valid-token', got %v", resp["access_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
// Mirroring must be enabled for HandleBoseProxy to work (based on previous changes)
|
||||
// Actually I reverted that, so it should work regardless of MirrorEnabled now.
|
||||
server.SetMirrorSettings(true, nil, "")
|
||||
|
||||
// chi.URLParam works when using chi router
|
||||
r := chi.NewRouter()
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken)
|
||||
|
||||
// Since there's no Spotify service, it should fall back to HandleBoseProxy.
|
||||
// HandleBoseProxy will try to contact streaming.bose.com.
|
||||
// We can check if it returns a 502 or 404 (since we are not actually proxying to real Bose).
|
||||
|
||||
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil)
|
||||
req.Host = "localhost" // use localhost to avoid real network call
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// If it fell back to proxy, it should NOT have X-Proxy-Origin: self
|
||||
if w.Header().Get("X-Proxy-Origin") == "self" {
|
||||
t.Errorf("Expected fallback to proxy, but got X-Proxy-Origin: self")
|
||||
}
|
||||
|
||||
// HandleBoseProxy sets X-Proxy-Origin: upstream
|
||||
if w.Header().Get("X-Proxy-Origin") != "upstream" {
|
||||
// It might fail before setting the header if the target host is invalid,
|
||||
// but our HandleBoseProxy sets it in ModifyResponse.
|
||||
// If it fails to connect, it might return 502 without the header.
|
||||
if w.Code != http.StatusBadGateway && w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected fallback to proxy (upstream), got status %d and origin %s", w.Code, w.Header().Get("X-Proxy-Origin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBoseSpotifyLegacyToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
server := NewServer(ds, nil, "http://localhost", false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken)
|
||||
|
||||
// Since we are not configuring Spotify, it should fall back to proxy
|
||||
req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token", nil)
|
||||
req.Host = "localhost"
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("X-Proxy-Origin") == "self" {
|
||||
t.Errorf("Expected fallback to proxy")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user